docs: merge deployment.md into README, delete deployment files

- Move operational content (env var reference, multi-instance prerequisites,
  troubleshooting, cron-drain, CF Dashboard Git integration, BACKEND_ORIGINS_JSON
  format, security checklist) into README and README.zh-CN
- Delete docs/deployment.md and docs/deployment.zh-CN.md
- Update 5 validator DOCS lists to remove deployment entries
- Update charter §7 doc boundary table, AGENTS §2.5 line-anchor exception
- Fix cross-doc references in design/reference/opengaps/retired/worker READMEs
- Fix cron-drain.sh header comment reference (deployment.md §6.3 → README §3.10)
- GATE-DOCS 6/6 PASS (55 anchors / 441 sec-refs / 699 path-refs, 0 errors)
This commit is contained in:
anonsaber 2026-10-10 18:53:43 +08:00
parent d5403d8b5b
commit 7aedcd4145
24 changed files with 342 additions and 762 deletions

View File

@ -73,7 +73,8 @@
重试上限」,第 120 行就必须是重试上限;行号漂移而描述跟着跑,比行号写错更危险。 重试上限」,第 120 行就必须是重试上限;行号漂移而描述跟着跑,比行号写错更危险。
- **校验器只能证明「该行号存在且非空」,不能证明该行内容符合描述。** 内容级核对必须人工读。 - **校验器只能证明「该行号存在且非空」,不能证明该行内容符合描述。** 内容级核对必须人工读。
- **文档引用代码一律用符号名,不写行号。** 行号随任何无关改动重排,符号名稳定。 - **文档引用代码一律用符号名,不写行号。** 行号随任何无关改动重排,符号名稳定。
`docs/deployment.md` 是既有例外(运维 runbook,事故中按行跳转,54 个锚点仍保留)。 (`docs/deployment.md` 原为运维 runbook 例外,已于 2026-10 合并入 `README.md` 并删除;
README 一律用符号名引用代码,不再保留行号锚点。)
`docs/reference.md` 的 56 个 `notify.rs` 锚点里约 40 个落在闭合括号、注释行或错误符号上, `docs/reference.md` 的 56 个 `notify.rs` 锚点里约 40 个落在闭合括号、注释行或错误符号上,
而门禁报 0 错,2026-09-29 起全部删除、不再回补。opengaps / retired / design 里还剩 17 个 而门禁报 0 错,2026-09-29 起全部删除、不再回补。opengaps / retired / design 里还剩 17 个
存量锚点,**不再新增、也不刻意清理**——它们改动频率低,不值得为此动文档。 存量锚点,**不再新增、也不刻意清理**——它们改动频率低,不值得为此动文档。

118
README.md
View File

@ -25,7 +25,10 @@ State is an architectural boundary here, not a preference.
- **No long-lived connections.** No SSE, no WebSocket, no long polling. - **No long-lived connections.** No SSE, no WebSocket, no long polling.
- **Logs go to stdout only.** The platform's collector owns log storage and retention. - **Logs go to stdout only.** The platform's collector owns log storage and retention.
- **What it is not.** It is not a mail client, not a proxy, and not a relay. It forwards - **What it is not.** It is not a mail client, not a proxy, and not a relay. It forwards
metadata to one bot; it does not read arbitrary URLs, open tunnels, or broker traffic. metadata to one bot; it does not read arbitrary URLs, open tunnels, or broker traffic. It has
no resident `server` mode flag, holds no EventSource/SSE or Telegram long-polling connection,
persists nothing to SQLite or a local volume, and is not bound to a specific serverless platform
— every notification path is a short request-response.
## 2. Two ways to run it ## 2. Two ways to run it
@ -51,7 +54,7 @@ is what makes it a load balancer for a bounded set of paths, not a firewall.
| Load balancer route set | n/a — there is no load balancer | fixed at 19 paths; unknown paths answer `404` | | Load balancer route set | n/a — there is no load balancer | fixed at 19 paths; unknown paths answer `404` |
| Health to watch | `GET /ready` on the origin | `GET /ready` proxied, plus `GET /healthz-worker` for the load balancer aggregate | | Health to watch | `GET /ready` on the origin | `GET /ready` proxied, plus `GET /healthz-worker` for the load balancer aggregate |
| Extra moving parts | none | one Worker deploy, one origin list | | Extra moving parts | none | one Worker deploy, one origin list |
| Docs | `docs/deployment.md §2` | `docs/deployment.md §10`, `cloudflare-worker/README.md` | | Docs | §3 below | §2 above, §6, `cloudflare-worker/README.md` |
Pick Form A for a single origin with no HA requirement. Pick Form B for two or more origins, or Pick Form A for a single origin with no HA requirement. Pick Form B for two or more origins, or
whenever you want one stable callback URL while you roll or rebuild an instance. whenever you want one stable callback URL while you roll or rebuild an instance.
@ -76,8 +79,8 @@ their own addresses in both forms.
This section is a hand-holding walkthrough: create a Telegram bot, deploy one backend, fill This section is a hand-holding walkthrough: create a Telegram bot, deploy one backend, fill
the configuration page, register the callbacks, get the first notification. Field semantics, the configuration page, register the callbacks, get the first notification. Field semantics,
defaults, TTLs and error codes live in `docs/reference.md`; production, scaling and load defaults, TTLs and error codes live in `docs/reference.md`; production, scaling and
balancer operations live in `docs/deployment.md`. load-balancer operation live in §6 below.
### 3.0 What you need ### 3.0 What you need
@ -92,6 +95,12 @@ balancer operations live in `docs/deployment.md`.
Nothing else is required: no Postgres, no message broker, no Kubernetes. Nothing else is required: no Postgres, no message broker, no Kubernetes.
The platform or proxy terminates TLS; the process listens on one HTTP port (`PORT`, default
`8080`) and exposes no other TCP port, and the public HTTPS URL is provided by the platform, not
the bot. The Docker image uses a Rust builder and a Debian slim runtime, links `rustls` with
native roots for outbound TLS, and carries no secret in its layers or build arguments. Back up
Redis with AOF persistence — it is the only state store.
### 3.1 Create the Telegram bot ### 3.1 Create the Telegram bot
1. In Telegram open a chat with `@BotFather` and send `/newbot`. 1. In Telegram open a chat with `@BotFather` and send `/newbot`.
@ -197,8 +206,7 @@ Secrets are write-only: the page never shows a stored secret again, and submitti
field keeps the previously stored value — clear a secret deliberately in a separate edit. field keeps the previously stored value — clear a secret deliberately in a separate edit.
If you run multiple backends behind a load balancer, configure on **any one node** — all backends If you run multiple backends behind a load balancer, configure on **any one node** — all backends
share one Redis and read the same configuration. See [the multi-instance section of the deployment share one Redis and read the same configuration. See [§6.1](#61-multi-instance-prerequisites) for the full model.
guide](docs/deployment.md#102-multi-instance-prerequisites) for the full model.
### 3.7 Register the callbacks ### 3.7 Register the callbacks
@ -257,7 +265,7 @@ scripts/cron-drain.sh
The script drains the queue (`/worker`) and rescans for missed pushes (`/reconcile`); both return The script drains the queue (`/worker`) and rescans for missed pushes (`/reconcile`); both return
204 with an empty body on success. `MW_RECONCILE_TOKEN` is optional — if omitted, the reconcile 204 with an empty body on success. `MW_RECONCILE_TOKEN` is optional — if omitted, the reconcile
step is skipped and only the queue is drained. If you put the load balancer in front, `/worker` step is skipped and only the queue is drained. If you put the load balancer in front, `/worker`
must reach the backend origin directly — see `docs/deployment.md` §10 for why. must reach the backend origin directly — see §6.3 for why.
### 3.11 When something is wrong ### 3.11 When something is wrong
@ -309,8 +317,8 @@ do not exist at all (requests fall through to a generic 404). Nothing under `/de
the load balancer allowlist, so it is reachable only on the backend origin itself. The only rule for the load balancer allowlist, so it is reachable only on the backend origin itself. The only rule for
running it is to keep it off: leave `--debug` out of the start command and leave `DEBUG_TOKEN` running it is to keep it off: leave `--debug` out of the start command and leave `DEBUG_TOKEN`
unset. If you do turn it on for a one-off diagnosis, configure the chat allowlist first — with unset. If you do turn it on for a one-off diagnosis, configure the chat allowlist first — with
an empty allowlist the test send is not restricted to any chat. See an empty allowlist the test send is not restricted to any chat. See `docs/reference.md` §3 for
`docs/deployment.md` §2.1. the endpoint contracts.
## 5. Security boundary, in one sentence ## 5. Security boundary, in one sentence
@ -328,12 +336,100 @@ It holds no credential and checks no auth header, and the backends behind it are
reachable either way, so nothing about it changes what stands between an attacker and your reachable either way, so nothing about it changes what stands between an attacker and your
mail. See §2. mail. See §2.
## 6. Where to read more ## 6. Operations and multi-instance
Production, scaling and load-balancer operation reference. The walkthrough in §3 covers a single
instance; this section covers what differs when more than one backend runs behind one or more
HTTPS origins.
### 6.1 Multi-instance prerequisites
Multiple backends can share traffic because request state is stored in Redis, duplicate
deliveries are deduplicated, and reconciliation repairs missed push events. No sticky session or
application code change is required.
All Redis keys are named globally and carry no instance, hostname, or origin suffix. Sharing one
Redis is not a compromise here: several origins hold a single copy of each item below.
| Data | Key | Semantics |
|---|---|---|
| Business configuration | `config:business` | one global ciphertext blob (AES-256-GCM), with no per-instance slot |
| Configuration revision | `config:business:revision` | one global monotonic counter, bumped in the same Lua transaction as the write (`CONFIG_STORE_REV_SCRIPT`) |
| Enable gate | `config:enabled` | one global boolean |
| Reconcile cursor | `state:jmap:since` | one global cursor, with no account dimension |
| Delivery streams | `stalwart:jmap:events` / `stalwart:telegram:events` | shared through a consumer group; the group is created lazily and `XAUTOCLAIM` reclaims idle messages |
| Reconcile lock | `lock:reconcile` | one global single-flight lock, mutual exclusion across instances |
Two consequences matter in practice.
First, **the business configuration exists once, and an SPA edit from any origin writes the same
copy.** There is no way to change only one instance: no key carries an instance dimension and
`refresh_business_config` takes no instance argument. This is intended behavior. If two genuinely
independent configurations are needed, for example production alongside a pre-production twin,
deploy a second instance pointed at a second Redis rather than distinguishing inside one Redis.
Second, **`CONFIG_ENCRYPTION_KEY` must be byte-identical on every origin.** It is 64 hex
characters (32 bytes) and is used directly as the AES-256-GCM key, with the AAD fixed at
`message-weave/config:business/v1`. It also doubles as the SPA admin credential. An origin holding
a different value can neither decrypt configuration written by another origin nor log in to the
SPA, and both paths fail closed.
Extra origins buy failover rather than throughput. Delivery is spread across the shared consumer
group, but `/reconcile` is serialized globally by `lock:reconcile` and the cursor is singular. If
one instance dies mid-batch, the next instance picks the messages up once they pass the
`XAUTOCLAIM` idle window; the cost is possible duplicate delivery, guarded by the `dedup:*` keys,
never message loss.
### 6.2 Configuration convergence across origins
One SPA edit writes once to Redis. The origins converge **lazily, at the request boundary**: each
entry point that calls `refresh_business_config` re-reads the global revision from Redis and
returns immediately when `remote_revision <= local_revision`. There is no polling loop and no
watch, so an origin that receives no traffic keeps the configuration it already had.
The entry points are `/ready`, `/worker`, `/reconcile`, `/push/jmap`, `/webhook/tg`,
`/api/config`, `/api/business-config` and `/api/telegram/register-webhook`. `GET /healthz` is
**not** among them — it is a static `200 "ok"` and never refreshes — and neither does
`GET /api/status`, `/api/enabled`, `/api/push/register`, `/api/push/disable` or the
`/api/admin/session` pair.
**Known behavior: a failed rebuild is not retried until the next revision.** The revision check
above is an early exit, not a retry guard. When `build_worker` fails for one origin — a JMAP
session URL that is unreachable, or an `llm_api_key` / `llm_base_url` that is missing or invalid —
that origin advances its in-memory revision to the remote value and keeps the runtime it already
had. The early-exit condition then holds forever for that origin, so it does not try again until a
**new** revision is written. Every other failure inside the same function (Redis unreachable, blob
missing, JSON parse failure, validation failure) leaves the local revision untouched and therefore
does retry on the next request; those paths fail before a runtime is built, so no worker is
rebuilt.
The symptom is one origin running the new configuration and another still on the old one, with no
error and no log line: the failed path returns silently. To detect it, compare the field values
reported by `GET /debug/config` on each origin — those come from the live in-memory runtime. Do not
use its `revision` field for this: that value is read from Redis and shows the latest global
revision even when the instance has not converged.
To recover, touch the configuration again from the SPA so that the revision moves; the stuck origin
will attempt the rebuild once. If the origin is still in the Worker's origin list, remove it in the
meanwhile so requests are not split between two configurations.
### 6.3 Reliability and delivery objective
The Worker retries only timeouts and backend `5xx` responses; it returns `4xx` responses directly.
`/reconcile` and `/worker` have longer per-route timeouts and do not fail over to a second backend,
avoiding duplicate work. Backend queue processing is at least once and relies on Redis-backed
idempotency. See the route and state reference for the complete behavior.
The service permits short notification delays and targets at least 99.9% notification availability.
Redis availability and the external scheduling interval are outside the application's control;
exact delivery guarantees are described in `NFR-NOTIFY-SLA` and `NFR-RECONCILE-INTERVAL` in the
project charter.
## 7. Where to read more
| Document | What it answers | | Document | What it answers |
|---|---| |---|---|
| [`docs/design.md`](docs/design.md) | Why the system is shaped this way: data flow, JMAP semantics, Redis streams, AI consent rules | | [`docs/design.md`](docs/design.md) | Why the system is shaped this way: data flow, JMAP semantics, Redis streams, AI consent rules |
| [`docs/deployment.md`](docs/deployment.md) | How to deploy: Dockerfile, secrets, Redis hosting, webhook/push/reconcile routing, multi-instance load balancing |
| [`docs/reference.md`](docs/reference.md) | **Single source of truth for verifiable facts** — Redis keys and TTLs, error codes, routes, environment layers, budgets | | [`docs/reference.md`](docs/reference.md) | **Single source of truth for verifiable facts** — Redis keys and TTLs, error codes, routes, environment layers, budgets |
| [`docs/opengaps.md`](docs/opengaps.md) | Gaps, blockers and the next phase | | [`docs/opengaps.md`](docs/opengaps.md) | Gaps, blockers and the next phase |
| [`docs/retired.md`](docs/retired.md) | What was tried and dropped — abandoned routes, unreleased designs, and names that never existed | | [`docs/retired.md`](docs/retired.md) | What was tried and dropped — abandoned routes, unreleased designs, and names that never existed |

View File

@ -23,7 +23,9 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
- **无长连接。** 无 SSE、无 WebSocket、无长轮询。 - **无长连接。** 无 SSE、无 WebSocket、无长轮询。
- **日志只写 stdout。** 日志存储与保留由平台采集器负责。 - **日志只写 stdout。** 日志存储与保留由平台采集器负责。
- **它不是什么。** 它不是邮件客户端,不是代理,也不是转发中继。它只把一个 bot 的元数据 - **它不是什么。** 它不是邮件客户端,不是代理,也不是转发中继。它只把一个 bot 的元数据
送达 Telegram;不抓取任意 URL、不开隧道、不中转流量。 送达 Telegram;不抓取任意 URL、不开隧道、不中转流量。它没有常驻 `server` 模式开关,不持有
EventSource/SSE 或 Telegram 长轮询连接,不向 SQLite 或本地卷持久化,也不绑定特定 serverless
平台——每条通知路径都是短请求-响应。
## 2. 两种部署形态 ## 2. 两种部署形态
@ -42,7 +44,7 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
| 负载均衡器路由集 | 不适用——没有负载均衡器 | 固定 19 个路径;未知路径返回 `404` | | 负载均衡器路由集 | 不适用——没有负载均衡器 | 固定 19 个路径;未知路径返回 `404` |
| 要看哪个健康接口 | origin 上的 `GET /ready` | 透传的 `GET /ready`,外加负载均衡器聚合 `GET /healthz-worker` | | 要看哪个健康接口 | origin 上的 `GET /ready` | 透传的 `GET /ready`,外加负载均衡器聚合 `GET /healthz-worker` |
| 额外组件 | 无 | 一个 Worker 部署、一份 origin 列表 | | 额外组件 | 无 | 一个 Worker 部署、一份 origin 列表 |
| 文档 | `docs/deployment.md §2` | `docs/deployment.md §10`、`cloudflare-worker/README.zh-CN.md` | | 文档 | 见下文 §3 | 上文 §2、§6、`cloudflare-worker/README.zh-CN.md` |
单 origin 且没有 HA 要求时选形态 A;两个及以上 origin、或想在滚动与重建某个实例时保持一个稳定的回调 URL,就选形态 B。 单 origin 且没有 HA 要求时选形态 A;两个及以上 origin、或想在滚动与重建某个实例时保持一个稳定的回调 URL,就选形态 B。
@ -54,7 +56,7 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
## 3. 从零开始 ## 3. 从零开始
本节是手把手教程:创建 Telegram bot、部署一个后端、填写配置页、注册回调、收到第一条通知。字段语义、默认值、TTL 与错误码在 `docs/reference.md`;生产、扩展与负载均衡器运维在 `docs/deployment.md`。 本节是手把手教程:创建 Telegram bot、部署一个后端、填写配置页、注册回调、收到第一条通知。字段语义、默认值、TTL 与错误码在 `docs/reference.md`;生产、扩展与负载均衡器运维见下文 §6。
### 3.0 你需要准备什么 ### 3.0 你需要准备什么
@ -67,6 +69,8 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
不需要别的:没有 Postgres、没有消息队列、没有 Kubernetes。 不需要别的:没有 Postgres、没有消息队列、没有 Kubernetes。
平台或反代终结 TLS;进程监听一个 HTTP 端口(`PORT`,默认 `8080`),不暴露任何其他 TCP 端口,公网 HTTPS URL 由平台提供,不是 bot 自己的。Docker 镜像用 Rust builder 与 Debian slim 运行时,外链 `rustls` 配原生根证书做出站 TLS,镜像层与构建参数里不带任何 secret。用 AOF 持久化备份 Redis——它是唯一的状态存储。
### 3.1 创建 Telegram bot ### 3.1 创建 Telegram bot
1. 在 Telegram 中打开 `@BotFather` 的对话,发送 `/newbot`。 1. 在 Telegram 中打开 `@BotFather` 的对话,发送 `/newbot`。
@ -138,7 +142,7 @@ curl localhost:8080/healthz # → ok
密钥是只写的:页面永不再显示已存密钥,提交空的密钥字段会保留之前的值——要清密钥就在单独一次编辑里故意留空。 密钥是只写的:页面永不再显示已存密钥,提交空的密钥字段会保留之前的值——要清密钥就在单独一次编辑里故意留空。
如果你在负载均衡器后面跑了多个后端,**在任意一台节点上配置即可**——所有后端共享同一个 Redis,读同一份配置。完整的多实例模型见[部署文档的多实例章节](docs/deployment.zh-CN.md#102-多实例前提)。 如果你在负载均衡器后面跑了多个后端,**在任意一台节点上配置即可**——所有后端共享同一个 Redis,读同一份配置。完整的多实例模型见 [§6.1](#61-多实例前提)。
### 3.7 注册回调 ### 3.7 注册回调
@ -177,7 +181,7 @@ MW_RECONCILE_TOKEN=<reconcile token> \
scripts/cron-drain.sh scripts/cron-drain.sh
``` ```
脚本会排空队列(`/worker`)并重扫漏掉的 push(`/reconcile`);两者成功都回答 204 空 body。`MW_RECONCILE_TOKEN` 是可选的——省略时跳过 reconcile 步骤,只排空队列。如果你把负载均衡器放在前面,`/worker` 必须直达后端源站——原因见 `docs/deployment.md` §10。 脚本会排空队列(`/worker`)并重扫漏掉的 push(`/reconcile`);两者成功都回答 204 空 body。`MW_RECONCILE_TOKEN` 是可选的——省略时跳过 reconcile 步骤,只排空队列。如果你把负载均衡器放在前面,`/worker` 必须直达后端源站——原因见 §6.3。
### 3.11 出问题时 ### 3.11 出问题时
@ -218,7 +222,7 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **
任一缺失则这些路由完全不存在(请求落到通用 404)。`/debug/*` 不在负载均衡器白名单内,因此只能 任一缺失则这些路由完全不存在(请求落到通用 404)。`/debug/*` 不在负载均衡器白名单内,因此只能
直连后端 origin 访问。运行它唯一的规则就是不要打开:启动命令里不加 `--debug`,环境变量里不 直连后端 origin 访问。运行它唯一的规则就是不要打开:启动命令里不加 `--debug`,环境变量里不
设 `DEBUG_TOKEN`。若确实要为一次排障临时开启,请先配好 chat 白名单——白名单为空时,测试 设 `DEBUG_TOKEN`。若确实要为一次排障临时开启,请先配好 chat 白名单——白名单为空时,测试
发送不会受限到任何 chat。见 `docs/deployment.md` §2.1。 发送不会受限到任何 chat。见 `docs/reference.zh-CN.md` §3 的端点契约。
## 5. 安全边界,一句话 ## 5. 安全边界,一句话
@ -228,12 +232,56 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **
前置在后端前面的可选 Cloudflare Worker 负载均衡器不在这个边界内。它不持有任何凭据,也不校验任何认证头;负载均衡器在不在,后端都同样可以直接访问,所以它不会改变攻击者与你的邮件之间的任何东西。见 §2。 前置在后端前面的可选 Cloudflare Worker 负载均衡器不在这个边界内。它不持有任何凭据,也不校验任何认证头;负载均衡器在不在,后端都同样可以直接访问,所以它不会改变攻击者与你的邮件之间的任何东西。见 §2。
## 6. 去哪读更多 ## 6. 运维与多实例
生产、扩展与负载均衡器运维参考。§3 的教程覆盖单实例;本节覆盖在多个后端、一个或多个 HTTPS 源站前面跑时有什么不同。
### 6.1 多实例前提
多个后端可以分担流量,因为请求状态存在 Redis、重复投递被去重、对账修复漏掉的 push 事件。不需要粘性会话,也不需要改应用代码。
所有 Redis 键都是全局命名,不带实例、主机名或源站后缀。这里共享一个 Redis 不是妥协:多个源站持有下面每一项的单一份副本。
| 数据 | 键 | 语义 |
|---|---|---|
| 业务配置 | `config:business` | 一个全局密文块(AES-256-GCM),没有按实例的槽 |
| 配置版本号 | `config:business:revision` | 一个全局单调计数器,在写入的同一 Lua 事务内自增(`CONFIG_STORE_REV_SCRIPT`) |
| 启用开关 | `config:enabled` | 一个全局布尔 |
| 对账游标 | `state:jmap:since` | 一个全局游标,没有账户维度 |
| 投递流 | `stalwart:jmap:events` / `stalwart:telegram:events` | 通过消费者组共享;组惰性创建,`XAUTOCLAIM` 回收空闲消息 |
| 对账锁 | `lock:reconcile` | 一个全局单飞锁,跨实例互斥 |
实践中两条推论最重要。
第一,**业务配置只有一份,任一源站的 SPA 编辑写入的是同一份副本。** 没有办法只改一个实例:没有任何键带实例维度,`refresh_business_config` 也不接受实例参数。这是有意行为。如果确实需要两份互相独立的配置——比如生产与一个预生产孪生——那就部署第二个实例、指向第二个 Redis,而不是在一个 Redis 内部分区。
第二,**`CONFIG_ENCRYPTION_KEY` 在每个源站上必须逐字节相同。** 它是 64 位十六进制(32 字节),直接用作 AES-256-GCM 密钥,AAD 固定为 `message-weave/config:business/v1`。它还兼作 SPA 管理员凭据。持不同值的源站既解不开别处写入的配置,也登不上 SPA,且两条路径都失败关闭。
多挂源站买的是故障转移,不是吞吐。投递在共享消费者组里分散,但 `/reconcile` 被 `lock:reconcile` 全局串行化、游标是单一的。如果某个实例在批次中途死掉,下一个实例会在消息越过 `XAUTOCLAIM` 空闲窗口后把它们捡起来;代价是可能的重复投递,由 `dedup:*` 键兜底,永远不会丢消息。
### 6.2 跨源站配置收敛
一次 SPA 编辑只往 Redis 写一次。各源站**惰性收敛,在请求边界上**:每个调用 `refresh_business_config` 的入口都从 Redis 重读全局版本号,当 `remote_revision <= local_revision` 时立即返回。没有轮询循环、没有 watch,所以收不到流量的源站保持它已有的配置。
入口是 `/ready`、`/worker`、`/reconcile`、`/push/jmap`、`/webhook/tg`、`/api/config`、`/api/business-config` 与 `/api/telegram/register-webhook`。`GET /healthz` **不**在其中——它是静态 `200 "ok"`、从不刷新——`GET /api/status`、`/api/enabled`、`/api/push/register`、`/api/push/disable` 与 `/api/admin/session` 这一对也都不是。
**已知行为:一次失败的重建在下一个版本号到来前不会被重试。** 上面的版本号检查是早退,不是重试守卫。当 `build_worker` 对某个源站失败时——JMAP session URL 不可达,或 `llm_api_key` / `llm_base_url` 缺失或非法——该源站把内存中的版本号推进到远端值,并保留它已有的运行时。于是早退条件对该源站永远成立,所以它直到**新**版本号被写入才会再试。同一函数内的其他失败(Redis 不可达、密文块缺失、JSON 解析失败、校验失败)不碰本地版本号,因此在下次请求时确实重试;这些路径在运行时构建之前就失败了,所以没有 worker 被重建。
症状是一个源站跑着新配置、另一个还停在旧配置,没有错误、没有日志行:失败路径静默返回。要检测它,比较各源站 `GET /debug/config` 报告的字段值——那些来自活动内存运行时。不要用它的 `revision` 字段做这件事:那个值是从 Redis 读的,显示的是最新全局版本号,即便该实例尚未收敛。
要恢复,从 SPA 再动一次配置让版本号前进;卡住的源站会再尝试一次重建。如果该源站还在 Worker 的源站列表里,在此期间先把它移除,免得请求被拆到两份配置之间。
### 6.3 可靠性与投递目标
Worker 只重试超时与后端 `5xx` 响应;`4xx` 响应直接返回。`/reconcile` 与 `/worker` 有更长的逐路由超时,且不向第二个后端故障转移,避免重复工作。后端队列处理是至少一次,依赖 Redis 支持的幂等。完整行为见路由与状态参考。
本服务允许短的投递延迟,目标至少 99.9% 的通知可用性。Redis 可用性与外部调度间隔不在应用控制之内;确切投递保证在项目章程的 `NFR-NOTIFY-SLA` 与 `NFR-RECONCILE-INTERVAL` 中描述。
## 7. 去哪读更多
| 文档 | 回答什么 | | 文档 | 回答什么 |
|---|---| |---|---|
| [`docs/design.zh-CN.md`](docs/design.zh-CN.md) | 为什么这样设计:数据流、JMAP 语义、Redis streams、AI 授权规则 | | [`docs/design.zh-CN.md`](docs/design.zh-CN.md) | 为什么这样设计:数据流、JMAP 语义、Redis streams、AI 授权规则 |
| [`docs/deployment.zh-CN.md`](docs/deployment.zh-CN.md) | 怎么部署:Dockerfile、secrets、Redis 托管、webhook/push/对账路由、多实例负载均衡 |
| [`docs/reference.zh-CN.md`](docs/reference.zh-CN.md) | **可核对事实的唯一权威来源**——Redis 键与 TTL、错误码、路由、环境变量分层、预算常量 | | [`docs/reference.zh-CN.md`](docs/reference.zh-CN.md) | **可核对事实的唯一权威来源**——Redis 键与 TTL、错误码、路由、环境变量分层、预算常量 |
| [`docs/opengaps.zh-CN.md`](docs/opengaps.zh-CN.md) | 缺口、阻塞项与下一阶段目标 | | [`docs/opengaps.zh-CN.md`](docs/opengaps.zh-CN.md) | 缺口、阻塞项与下一阶段目标 |
| [`docs/retired.zh-CN.md`](docs/retired.zh-CN.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 | | [`docs/retired.zh-CN.md`](docs/retired.zh-CN.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 |

View File

@ -26,7 +26,7 @@ business payloads and forwards the request as-is to the backend https origins; o
> access control is added at the edge, so the route safelist is not a firewall and nothing in > access control is added at the edge, so the route safelist is not a firewall and nothing in
> this document is meant to be read as protection. > this document is meant to be read as protection.
> Design basis: `docs/design.md §11.3` (`NFR-HA-MULTI-INSTANCE`) and `docs/deployment.md §10`. > Design basis: `docs/design.md §11.3` (`NFR-HA-MULTI-INSTANCE`) and `docs/charter.md` §8.
> Security baseline and prohibitions: `docs/charter.md §3`, `docs/charter.md §5`; stable-ID > Security baseline and prohibitions: `docs/charter.md §3`, `docs/charter.md §5`; stable-ID
> registry `docs/charter.md §8`. > registry `docs/charter.md §8`.
@ -300,7 +300,7 @@ Two production incidents worth keeping in mind:
## References ## References
- `docs/deployment.md §10` — load balancer deployment, configuration and rollout - `docs/reference.md §9.5` — load balancer deployment, configuration and rollout
- `docs/reference.md §4` — load balancer versus backend route matrix - `docs/reference.md §4` — load balancer versus backend route matrix
- `docs/reference.md §9.2` — backend configuration and secrets - `docs/reference.md §9.2` — backend configuration and secrets
- `docs/reference.md §9.4` — health and release checks - `docs/reference.md §9.4` — health and release checks

View File

@ -10,7 +10,7 @@
> >
> **它不是安全特性。** 这里没有隐藏后端的意图,也没有加固后端的意图。负载均衡器在不在,origin 都同样可以直接访问,边缘上也没有加任何访问控制,所以路由白名单不是防火墙,本文档任何一处都不该被读成防护。 > **它不是安全特性。** 这里没有隐藏后端的意图,也没有加固后端的意图。负载均衡器在不在,origin 都同样可以直接访问,边缘上也没有加任何访问控制,所以路由白名单不是防火墙,本文档任何一处都不该被读成防护。
> 设计依据:`docs/design.md §11.3`(`NFR-HA-MULTI-INSTANCE`)与 `docs/deployment.md §10`。 > 设计依据:`docs/design.md §11.3`(`NFR-HA-MULTI-INSTANCE`)与 `README.md §6.1`。
> 安全基线与禁令:`docs/charter.md §3`、`docs/charter.md §5`;稳定 ID 注册表 `docs/charter.md §8`。 > 安全基线与禁令:`docs/charter.md §3`、`docs/charter.md §5`;稳定 ID 注册表 `docs/charter.md §8`。
## 1. 它是什么,不是什么 ## 1. 它是什么,不是什么
@ -191,7 +191,7 @@ curl https://<your-worker>.workers.dev/healthz-worker
## References ## References
- `docs/deployment.md §10` — 负载均衡器部署、配置与上线 - `docs/reference.md §9.5` — 负载均衡器部署、配置与上线
- `docs/reference.md §4` — 负载均衡器与后端的路由矩阵 - `docs/reference.md §4` — 负载均衡器与后端的路由矩阵
- `docs/reference.md §9.2` — 后端配置与 secret - `docs/reference.md §9.2` — 后端配置与 secret
- `docs/reference.md §9.4` — 健康与发布检查 - `docs/reference.md §9.4` — 健康与发布检查

View File

@ -170,9 +170,8 @@ Answer only one question per document; **tables, lists and values must not be du
| Documentation | What to answer | Scope of authority | | Documentation | What to answer | Scope of authority |
|---|---|---| |---|---|---|
| [`../AGENTS.md`](../AGENTS.md) | Universal, language-independent code writing and environment building specifications | Quality rules | | [`../AGENTS.md`](../AGENTS.md) | Universal, language-independent code writing and environment building specifications | Quality rules |
| `README.md` / `README.zh-CN.md` | User-oriented: what is it, how to run it, how to configure it | User-visible facts (information must be equivalent in Chinese and English) | | `README.md` / `README.zh-CN.md` | User-oriented: what is it, how to run it, how to configure it, and operational and multi-instance reference | User-visible facts (information must be equivalent in Chinese and English) |
| `docs/design.md` | Why it is designed this way: data flow, module boundaries, state machines, error handling | Architectural intent | | `docs/design.md` | Why it is designed this way: data flow, module boundaries, state machines, error handling | Architectural intent |
| `docs/deployment.md` | How to deploy: Dockerfile, secrets, load balancer, multiple instances, cron | Deployment and operation and maintenance |
| `docs/reference.md` | The single authoritative source of verifiable facts: routing, Redis keys and TTLs, configuration items, error codes, outbound constants | **verifiable facts** | | `docs/reference.md` | The single authoritative source of verifiable facts: routing, Redis keys and TTLs, configuration items, error codes, outbound constants | **verifiable facts** |
| `docs/opengaps.md` | Gaps, Blockages, Next Stage Goals | Open Items | | `docs/opengaps.md` | Gaps, Blockages, Next Stage Goals | Open Items |
| `docs/retired.md` | Records and alternative links to abandoned or renamed plans | Historical decisions | | `docs/retired.md` | Records and alternative links to abandoned or renamed plans | Historical decisions |
@ -203,29 +202,29 @@ Answer only one question per document; **tables, lists and values must not be du
| ID | Definition file | One sentence | Category | | ID | Definition file | One sentence | Category |
|---|---|---|---| |---|---|---|---|
| `C-DOCKER` | docs/deployment.md §0 | Docker deployment required | Deployment constraints | | `C-DOCKER` | README.md §3.0 | Docker deployment required | Deployment constraints |
| `C-DEBIAN-SLIM` | docs/deployment.md §0 | Debian slim, disable Alpine | Deployment constraints | | `C-DEBIAN-SLIM` | README.md §3.0 | Debian slim, disable Alpine | Deployment constraints |
| `C-NO-SECRET-IN-IMAGE` | docs/deployment.md §0 | secrets are not included in the image | Deployment constraints | | `C-NO-SECRET-IN-IMAGE` | README.md §3.0 | secrets are not included in the image | Deployment constraints |
| `C-RUSTLS` | docs/deployment.md §0 | rustls + native-roots | Deployment constraints | | `C-RUSTLS` | README.md §3.0 | rustls + native-roots | Deployment constraints |
| `C-HTTPS-INBOUND` | docs/deployment.md §0 | HTTPS-only inbound, plaintext HTTP within the container | Deployment constraints | | `C-HTTPS-INBOUND` | README.md §3.0 | HTTPS-only inbound, plaintext HTTP within the container | Deployment constraints |
| `C-HTTPS-URL` | docs/deployment.md §0 | Public HTTPS URL is provided by the platform (bot does not hold a certificate) | Deployment constraints | | `C-HTTPS-URL` | README.md §3.0 | Public HTTPS URL is provided by the platform (bot does not hold a certificate) | Deployment constraints |
| `C-AUTH-APP-BASIC` | docs/deployment.md §9 / docs/design.md §3.1 | Stalwart Authentication = App Password + Basic | Deployment Constraints | | `C-AUTH-APP-BASIC` | docs/design.md §3.1 | Stalwart Authentication = App Password + Basic | Deployment Constraints |
| `C-NO-TCP-EXPOSE` | docs/deployment.md §0 | Single listener `PORT`, no additional TCP ports exposed | Deployment constraints | | `C-NO-TCP-EXPOSE` | README.md §3.0 | Single listener `PORT`, no additional TCP ports exposed | Deployment constraints |
| `C-NO-LONG-CONN` | docs/deployment.md §0 | No SSE/WS/long-polling equal-length connections | Deployment constraints | | `C-NO-LONG-CONN` | README.md §1 | No SSE/WS/long-polling equal-length connections | Deployment constraints |
| `C-REDIS-ONLY-STATE` | docs/deployment.md §0 | State only external Redis, not SQLite/local volumes | Deployment constraints | | `C-REDIS-ONLY-STATE` | README.md §3.0 | State only external Redis, not SQLite/local volumes | Deployment constraints |
| `C-REDIS-MANAGED-AOF` | docs/deployment.md §0 | Redis user hosting + enable AOF persistence | Deployment constraints | | `C-REDIS-MANAGED-AOF` | README.md §3.0 | Redis user hosting + enable AOF persistence | Deployment constraints |
| `C-PORT` | docs/deployment.md §0 | Common PORT conventions | Deployment constraints | | `C-PORT` | README.md §3.0 | Common PORT conventions | Deployment constraints |
| `NG-SERVER-MODE` | docs/deployment.md §1 | `RUN_MODE=server` resident, non-target (this variable has been removed with `Config::from_env()`, this identifier is no longer in the code) | non-target | | `NG-SERVER-MODE` | README.md §1 | `RUN_MODE=server` resident, non-target (this variable has been removed with `Config::from_env()`, this identifier is no longer in the code) | non-target |
| `NG-POLLING-SSE` | docs/deployment.md §1 | EventSource/SSE long-lived, non-target | non-target | | `NG-POLLING-SSE` | README.md §1 | EventSource/SSE long-lived, non-target | non-target |
| `NG-LONG-POLLING` | docs/deployment.md §1 | Telegram long polling, non-target | non-target | | `NG-LONG-POLLING` | README.md §1 | Telegram long polling, non-target | non-target |
| `NG-SQLITE-PERSIST` | docs/deployment.md §1 | SQLite persistence, non-target | non-target | | `NG-SQLITE-PERSIST` | README.md §1 | SQLite persistence, non-target | non-target |
| `NG-LOCAL-VOLUME` | docs/deployment.md §1 | Local volume persistence, non-target | non-target | | `NG-LOCAL-VOLUME` | README.md §1 | Local volume persistence, non-target | non-target |
| `NG-SERVERLESS-BIND` | docs/deployment.md §1 | Bind to specific serverless platform, non-target | non-target | | `NG-SERVERLESS-BIND` | README.md §1 | Bind to specific serverless platform, non-target | non-target |
| `MOD-DEDUP` | docs/deployment.md §6 | Redis `SET NX` idempotent key | Components | | `MOD-DEDUP` | README.md §6.1 | Redis `SET NX` idempotent key | Components |
| `MOD-STREAMS` | docs/deployment.md §6 | Redis Streams queue + worker | Components | | `MOD-STREAMS` | README.md §6.1 | Redis Streams queue + worker | Components |
| `MOD-SINCESTATE` | docs/deployment.md §6 | sinceState cursor (stored in Redis) | Components | | `MOD-SINCESTATE` | README.md §6.1 | sinceState cursor (stored in Redis) | Components |
| `FLOW-NEW-MAIL` | docs/design.md §5.4 / docs/deployment.md §6 | Push new mail flow | Data flow | | `FLOW-NEW-MAIL` | docs/design.md §5.4 | Push new mail flow | Data flow |
| `FLOW-RECONCILE` | docs/deployment.md §6 | External Cron reconciliation + Redis loss recovery | Data flow | | `FLOW-RECONCILE` | README.md §3.10 | External Cron reconciliation + Redis loss recovery | Data flow |
| `REQ-AI-CONFIRM` | docs/design.md §12 | AI only touches text after explicit request + confirmation | Requirements | | `REQ-AI-CONFIRM` | docs/design.md §12 | AI only touches text after explicit request + confirmation | Requirements |
| `REQ-VIEW-DIRECT` | docs/design.md §12 | View original text always JMAP direct fetch | Requirements | | `REQ-VIEW-DIRECT` | docs/design.md §12 | View original text always JMAP direct fetch | Requirements |
| `REQ-LONG-EMAIL` | docs/design.md §12 | It is forbidden to send the full text of long emails, AI abstract ~300 words | Requirements | | `REQ-LONG-EMAIL` | docs/design.md §12 | It is forbidden to send the full text of long emails, AI abstract ~300 words | Requirements |
@ -239,8 +238,8 @@ Answer only one question per document; **tables, lists and values must not be du
| `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:110 Comments | `PushSubscription/set` create does not have the `types` parameter in jmap-client 0.4.2; the subscription id must be narrowed to `Email` + `EmailDelivery` by `push_subscription_update_types` before being exposed to the outside world | Requirements | | `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:110 Comments | `PushSubscription/set` create does not have the `types` parameter in jmap-client 0.4.2; the subscription id must be narrowed to `Email` + `EmailDelivery` by `push_subscription_update_types` before being exposed to the outside world | Requirements |
| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP reconciliation cursor is only advanced after all paging events are successfully enqueued (XADD); a single reconciliation is guaranteed to fly solo (TTL) by Redis SET NX EX lock `lock:reconcile` 300s, owner token is renewed for 90s, only the holder can renew/release); the processing end then goes through `claim_dedup` (SET NX EX, 86400s) to ensure that the same flow message is not delivered repeatedly | Requirements | | `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP reconciliation cursor is only advanced after all paging events are successfully enqueued (XADD); a single reconciliation is guaranteed to fly solo (TTL) by Redis SET NX EX lock `lock:reconcile` 300s, owner token is renewed for 90s, only the holder can renew/release); the processing end then goes through `claim_dedup` (SET NX EX, 86400s) to ensure that the same flow message is not delivered repeatedly | Requirements |
| `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | Notification receipt time is configured by business `timezone` (IANA, default `Asia/Shanghai`) is rendered as `%Y-%m-%d %H:%M`; only 16 non-daylight saving time zones are accepted, no match is returned 422, no time zone library inference | Requirements | | `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | Notification receipt time is configured by business `timezone` (IANA, default `Asia/Shanghai`) is rendered as `%Y-%m-%d %H:%M`; only 16 non-daylight saving time zones are accepted, no match is returned 422, no time zone library inference | Requirements |
| `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | Notification availability ≥99.9%, minor latency allowed | Non-functional | | `NFR-NOTIFY-SLA` | README.md §6.3 | Notification availability ≥99.9%, minor latency allowed | Non-functional |
| `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | External cron reconciliation interval 5–10 minutes | Non-functional | | `NFR-RECONCILE-INTERVAL` | README.md §3.10 | External cron reconciliation interval 5–10 minutes | Non-functional |
| `SAF-NOTIFY-META` | §3 | New email notifications only contain metadata, and the text is not included in the notification | Security | | `SAF-NOTIFY-META` | §3 | New email notifications only contain metadata, and the text is not included in the notification | Security |
| `SAF-CHAT-ALLOWLIST` | §3/docs/design.md §7.3 | CHAT_ALLOWLIST hard constraint, reject non-whitelist before processing | Security | | `SAF-CHAT-ALLOWLIST` | §3/docs/design.md §7.3 | CHAT_ALLOWLIST hard constraint, reject non-whitelist before processing | Security |
| `SAF-AUTH-RECONCILE` | §3/docs/design.md §7.3 | `/reconcile` requires `Authorization: Bearer RECONCILE_TOKEN`, fail-closed | Security | | `SAF-AUTH-RECONCILE` | §3/docs/design.md §7.3 | `/reconcile` requires `Authorization: Bearer RECONCILE_TOKEN`, fail-closed | Security |
@ -265,27 +264,27 @@ Answer only one question per document; **tables, lists and values must not be du
| `SAF-JMAP-URL` | docs/design.md §7.1 / §4 | JMAP URL constraints: HTTPS only, disallow inline credentials, deny dangerous query | Security | | `SAF-JMAP-URL` | docs/design.md §7.1 / §4 | JMAP URL constraints: HTTPS only, disallow inline credentials, deny dangerous query | Security |
| `REQ-JMAP-RAW-MULTIPART` | docs/design.md §3.2/§10.1 | `read_email` Multi-part Original text: Splice the parts "with part_id and bodyValue" in text_body order; no available part → clear error | Requirements | | `REQ-JMAP-RAW-MULTIPART` | docs/design.md §3.2/§10.1 | `read_email` Multi-part Original text: Splice the parts "with part_id and bodyValue" in text_body order; no available part → clear error | Requirements |
| `GATE-G1-JMAP-READONLY` | docs/design.md §10.1 / §6 | G1 access control: read-only adapter **Code has been implemented** (mock + `#[ignore]` real machine test), **to be real `cargo test -- --ignored jmap::` verification** | Process | | `GATE-G1-JMAP-READONLY` | docs/design.md §10.1 / §6 | G1 access control: read-only adapter **Code has been implemented** (mock + `#[ignore]` real machine test), **to be real `cargo test -- --ignored jmap::` verification** | Process |
| `ARCH-LB-WORKER` | docs/deployment.md §10 | Multi-instance LB/HA: a free-plan Cloudflare Worker as the only *registered* entrance, plus failover. The backends stay directly reachable and it adds no access control | Architecture | | `ARCH-LB-WORKER` | README.md §2 | Multi-instance LB/HA: a free-plan Cloudflare Worker as the only *registered* entrance, plus failover. The backends stay directly reachable and it adds no access control | Architecture |
| `C-LB-SINGLE-REG-URL` | docs/deployment.md §10.1 | Telegram/Push/Cron only registers the stable URL of the Worker; the backend platform entrance does not register externally | Constraints | | `C-LB-SINGLE-REG-URL` | README.md §2 | Telegram/Push/Cron only registers the stable URL of the Worker; the backend platform entrance does not register externally | Constraints |
| `C-LB-SHARED-SECRETS` | docs/deployment.md §10.3 | Multiple instances must share the same set of `SAF-AUTH-*` secrets, otherwise a random 401 | Constraints | | `C-LB-SHARED-SECRETS` | README.md §2 | Multiple instances must share the same set of `SAF-AUTH-*` secrets, otherwise a random 401 | Constraints |
| `SAF-LB-PASSTHRU` | docs/deployment.md §10.3 / §3 | Trust model = transparent transmission: Worker does not overwrite authentication; the backend must continue fail-closed verification (the backend may be directly connected to the public network) | Security | | `SAF-LB-PASSTHRU` | §3 | Trust model = transparent transmission: Worker does not overwrite authentication; the backend must continue fail-closed verification (the backend may be directly connected to the public network) | Security |
| `SAF-RECONCILE-LOCK` | docs/deployment.md §10.5 | `/reconcile` does not fan out, Redis lock guarantees single instance execution and avoids repeated reconciliation | Security | | `SAF-RECONCILE-LOCK` | README.md §6.1 | `/reconcile` does not fan out, Redis lock guarantees single instance execution and avoids repeated reconciliation | Security |
| `MOD-STREAMS-GROUP` | docs/deployment.md §10.5 | Multiple instances use the same Streams consumer group name, and Redis will automatically allocate it (at-least-once will not repeat processing) | Components | | `MOD-STREAMS-GROUP` | README.md §6.1 | Multiple instances use the same Streams consumer group name, and Redis will automatically allocate it (at-least-once will not repeat processing) | Components |
| `MOD-HEALTH-AGG` | docs/deployment.md §10.6 | Worker aggregate health view, reporting the survival of each backend for external monitoring | Components | | `MOD-HEALTH-AGG` | README.md §2 | Worker aggregate health view, reporting the survival of each backend for external monitoring | Components |
| `MOD-DEBUG` | src/debug.rs / docs/deployment.md §2.1 / docs/design.md §7.6 / docs/reference.md §3 | Remote joint debugging read-only surface: `DEBUG_ENABLED` (or `--debug`) + `DEBUG_TOKEN` mounted after double factor is enabled `/debug/*`, otherwise it will not be mounted | component | | `MOD-DEBUG` | src/debug.rs / docs/design.md §7.6 / docs/reference.md §3 | Remote joint debugging read-only surface: `DEBUG_ENABLED` (or `--debug`) + `DEBUG_TOKEN` mounted after double factor is enabled `/debug/*`, otherwise it will not be mounted | component |
| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | Two-factor gate: "`DEBUG_ENABLED` is true or command line with `--debug`" **and** `DEBUG_TOKEN` Routes are mounted only if they are not empty; if any are missing, they are not mounted at all (the request fails with `404`), and it is absolutely closed by default. The start signal goes through env instead of argv, so that the startup command remains static, and the switch can be switched at a single point on the platform console | Security | | `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 | Two-factor gate: "`DEBUG_ENABLED` is true or command line with `--debug`" **and** `DEBUG_TOKEN` Routes are mounted only if they are not empty; if any are missing, they are not mounted at all (the request fails with `404`), and it is absolutely closed by default. The start signal goes through env instead of argv, so that the startup command remains static, and the switch can be switched at a single point on the platform console | Security |
| `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | After mounting `/debug/*` requires `Authorization: Bearer DEBUG_TOKEN` constant time comparison, fails with `401` and has no side effects | Security | | `SAF-DEBUG-AUTH` | src/debug.rs | After mounting `/debug/*` requires `Authorization: Bearer DEBUG_TOKEN` constant time comparison, fails with `401` and has no side effects | Security |
| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | Endpoint contracts: `GET /debug/ping`, `/config`, `/redis`, `/jmap`, `/telegram`, `/worker` are all read-only; `POST /debug/notify` Send a test message through the real outbound link; the response body does not contain the original text of secret (the credential field only returns `*_configured` Boolean, and the non-ciphertext identity and budget fields are still returned in plain text) | Requirements | | `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 | Endpoint contracts: `GET /debug/ping`, `/config`, `/redis`, `/jmap`, `/telegram`, `/worker` are all read-only; `POST /debug/notify` Send a test message through the real outbound link; the response body does not contain the original text of secret (the credential field only returns `*_configured` Boolean, and the non-ciphertext identity and budget fields are still returned in plain text) | Requirements |
| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` is not among the 19 safe routes of the load balancer, and the Worker will always receive `404 route not forwarded`; it can only be directly connected to the backend origin, and the public network is unreachable | Security | | `SAF-DEBUG-ORIGIN-ONLY` | docs/reference.md §4 | `/debug/*` is not among the 19 safe routes of the load balancer, and the Worker will always receive `404 route not forwarded`; it can only be directly connected to the backend origin, and the public network is unreachable | Security |
| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` only verifies `chat_id` when the chat whitelist** is not empty**; it will not intercept when the whitelist is not configured (empty), so to enable this page, you must confirm that the business whitelist has been configured | Security | | `SAF-DEBUG-ALLOWLIST` | src/debug.rs | `POST /debug/notify` only verifies `chat_id` when the chat whitelist** is not empty**; it will not intercept when the whitelist is not configured (empty), so to enable this page, you must confirm that the business whitelist has been configured | Security |
| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | Multi-instance high availability semantics; both active-active or active-standby; Redis single point of failure is not within the scope of the solution (user external solution) | Non-functional | | `NFR-HA-MULTI-INSTANCE` | README.md §6.1 | Multi-instance high availability semantics; both active-active or active-standby; Redis single point of failure is not within the scope of the solution (user external solution) | Non-functional |
| `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | Production does not use any database (no SQLite/Postgres/MySQL/embedded), Redis is the only state store; the application does not connect to a second database | Constraints | | `C-NO-DB` | §3 | Production does not use any database (no SQLite/Postgres/MySQL/embedded), Redis is the only state store; the application does not connect to a second database | Constraints |
| `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / §3 | Disable local file/directory writes (log/data/tempcache/local volumes) | Constraints | | `C-NO-LOCAL-WRITE` | §3 | Disable local file/directory writes (log/data/tempcache/local volumes) | Constraints |
| `C-LOG-STDOUT-ONLY` | docs/deployment.md §0 / §9.1 / §3 | The log only writes stdout/stderr, collected by the platform; disables the file log backend | Constraints | | `C-LOG-STDOUT-ONLY` | §3 | The log only writes stdout/stderr, collected by the platform; disables the file log backend | Constraints |
| `SAF-LOG-PURITY` | docs/deployment.md §0 / §9.1 / §3 | Log and Redis write content is limited to structured events/counts/timestamps/masked summaries; keys/email bodies/AI request responses/attachment content are prohibited | Security | | `SAF-LOG-PURITY` | §3 | Log and Redis write content is limited to structured events/counts/timestamps/masked summaries; keys/email bodies/AI request responses/attachment content are prohibited | Security |
| `SAF-ENABLE-FLAG` | src/notify.rs `put_enabled` / src/state.rs `config:enabled` | The global switch is Redis single key `config:enabled`; if it is not written, it is regarded as closed, and `business_enabled` will be treated as closed if an error occurs (fail-closed); writing requires admin-session Bearer | Security | | `SAF-ENABLE-FLAG` | src/notify.rs `put_enabled` / src/state.rs `config:enabled` | The global switch is Redis single key `config:enabled`; if it is not written, it is regarded as closed, and `business_enabled` will be treated as closed if an error occurs (fail-closed); writing requires admin-session Bearer | Security |
| `C-NO-STATEFUL-RECOVERY` | docs/deployment.md §0 / §9.1 / §3 | It is prohibited to rely on in-process status for production recovery; all recovery will use Redis + JMAP reconciliation; in-process caching is only for performance optimization, and loss must be safe and reentrant | Constraints | | `C-NO-STATEFUL-RECOVERY` | §3 | It is prohibited to rely on in-process status for production recovery; all recovery will use Redis + JMAP reconciliation; in-process caching is only for performance optimization, and loss must be safe and reentrant | Constraints |
| `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis is the only state source | The state layer uses Redis (Streams/SET NX/Lock/Abstract), and the process does not hold recoverable state | Architecture | | `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis is the only state source | The state layer uses Redis (Streams/SET NX/Lock/Abstract), and the process does not hold recoverable state | Architecture |
| `C-REDIS-EXTERNAL` | docs/deployment.md §8.1 | Redis is provided by an external certified instance and is not in the same container as this service | Deployment constraints | | `C-REDIS-EXTERNAL` | README.md §3.0 | Redis is provided by an external certified instance and is not in the same container as this service | Deployment constraints |
| `GATE-UPTIME-KUMA` | docs/deployment.md §10.1 | `/healthz` stable semantics can be directly connected to external probes such as Uptime Kuma | Access Control | | `GATE-UPTIME-KUMA` | docs/reference.md §9.4 | `/healthz` stable semantics can be directly connected to external probes such as Uptime Kuma | Access Control |
| `GATE-DOCS` | scripts/docs_check/run_all.sh / docs/deployment.md | Document access control: All validators must have 0 error and exit 0 to be considered passed | Access control | | `GATE-DOCS` | scripts/docs_check/run_all.sh | Document access control: All validators must have 0 error and exit 0 to be considered passed | Access control |

View File

@ -170,9 +170,8 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
| 文档 | 回答什么 | 权威范围 | | 文档 | 回答什么 | 权威范围 |
|---|---|---| |---|---|---|
| [`../AGENTS.md`](../AGENTS.md) | 通用、语言无关的代码编写与环境构建规范 | 质量规则 | | [`../AGENTS.md`](../AGENTS.md) | 通用、语言无关的代码编写与环境构建规范 | 质量规则 |
| `README.md` / `README.zh-CN.md` | 面向使用者:是什么、怎么跑、怎么配置 | 用户可见事实(中英必须信息对等) | | `README.md` / `README.zh-CN.md` | 面向使用者:是什么、怎么跑、怎么配置、以及运维与多实例参考 | 用户可见事实(中英必须信息对等) |
| `docs/design.md` | 为什么这样设计:数据流、模块边界、状态机、错误处理 | 架构意图 | | `docs/design.md` | 为什么这样设计:数据流、模块边界、状态机、错误处理 | 架构意图 |
| `docs/deployment.md` | 怎么部署:Dockerfile、secrets、负载均衡器、多实例、cron | 部署与运维 |
| `docs/reference.md` | 可核对事实的唯一权威来源:路由、Redis 键与 TTL、配置项、错误码、出站常量 | **可核对事实** | | `docs/reference.md` | 可核对事实的唯一权威来源:路由、Redis 键与 TTL、配置项、错误码、出站常量 | **可核对事实** |
| `docs/opengaps.md` | 缺口、阻塞、下一阶段目标 | 未决项 | | `docs/opengaps.md` | 缺口、阻塞、下一阶段目标 | 未决项 |
| `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 | | `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 |
@ -203,29 +202,29 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
| ID | 定义文件 | 一句话 | 类别 | | ID | 定义文件 | 一句话 | 类别 |
|---|---|---|---| |---|---|---|---|
| `C-DOCKER` | docs/deployment.md §0 | 必须 Docker 部署 | 部署约束 | | `C-DOCKER` | README.md §3.0 | 必须 Docker 部署 | 部署约束 |
| `C-DEBIAN-SLIM` | docs/deployment.md §0 | Debian slim,禁 Alpine | 部署约束 | | `C-DEBIAN-SLIM` | README.md §3.0 | Debian slim,禁 Alpine | 部署约束 |
| `C-NO-SECRET-IN-IMAGE` | docs/deployment.md §0 | secrets 不进镜像 | 部署约束 | | `C-NO-SECRET-IN-IMAGE` | README.md §3.0 | secrets 不进镜像 | 部署约束 |
| `C-RUSTLS` | docs/deployment.md §0 | rustls + native-roots | 部署约束 | | `C-RUSTLS` | README.md §3.0 | rustls + native-roots | 部署约束 |
| `C-HTTPS-INBOUND` | docs/deployment.md §0 | HTTPS-only 入站,容器内明文 HTTP | 部署约束 | | `C-HTTPS-INBOUND` | README.md §3.0 | HTTPS-only 入站,容器内明文 HTTP | 部署约束 |
| `C-HTTPS-URL` | docs/deployment.md §0 | 公网 HTTPS URL 由平台提供(bot 不持证书) | 部署约束 | | `C-HTTPS-URL` | README.md §3.0 | 公网 HTTPS URL 由平台提供(bot 不持证书) | 部署约束 |
| `C-AUTH-APP-BASIC` | docs/deployment.md §9 / docs/design.md §3.1 | Stalwart 认证 = App Password + Basic | 部署约束 | | `C-AUTH-APP-BASIC` | docs/design.md §3.1 | Stalwart 认证 = App Password + Basic | 部署约束 |
| `C-NO-TCP-EXPOSE` | docs/deployment.md §0 | 单监听 `PORT`,不暴露附加 TCP 端口 | 部署约束 | | `C-NO-TCP-EXPOSE` | README.md §3.0 | 单监听 `PORT`,不暴露附加 TCP 端口 | 部署约束 |
| `C-NO-LONG-CONN` | docs/deployment.md §0 | 无 SSE/WS/长轮询等长连接 | 部署约束 | | `C-NO-LONG-CONN` | README.md §1 | 无 SSE/WS/长轮询等长连接 | 部署约束 |
| `C-REDIS-ONLY-STATE` | docs/deployment.md §0 | 状态仅外部 Redis,不用 SQLite/本地卷 | 部署约束 | | `C-REDIS-ONLY-STATE` | README.md §3.0 | 状态仅外部 Redis,不用 SQLite/本地卷 | 部署约束 |
| `C-REDIS-MANAGED-AOF` | docs/deployment.md §0 | Redis 用户托管 + 开启 AOF 持久化 | 部署约束 | | `C-REDIS-MANAGED-AOF` | README.md §3.0 | Redis 用户托管 + 开启 AOF 持久化 | 部署约束 |
| `C-PORT` | docs/deployment.md §0 | 通用 PORT 约定 | 部署约束 | | `C-PORT` | README.md §3.0 | 通用 PORT 约定 | 部署约束 |
| `NG-SERVER-MODE` | docs/deployment.md §1 | `RUN_MODE=server` 常驻,非目标(该变量已随 `Config::from_env()` 删除,代码中已无此标识符) | 非目标 | | `NG-SERVER-MODE` | README.md §1 | `RUN_MODE=server` 常驻,非目标(该变量已随 `Config::from_env()` 删除,代码中已无此标识符) | 非目标 |
| `NG-POLLING-SSE` | docs/deployment.md §1 | EventSource/SSE 长连接,非目标 | 非目标 | | `NG-POLLING-SSE` | README.md §1 | EventSource/SSE 长连接,非目标 | 非目标 |
| `NG-LONG-POLLING` | docs/deployment.md §1 | Telegram 长轮询,非目标 | 非目标 | | `NG-LONG-POLLING` | README.md §1 | Telegram 长轮询,非目标 | 非目标 |
| `NG-SQLITE-PERSIST` | docs/deployment.md §1 | SQLite 持久化,非目标 | 非目标 | | `NG-SQLITE-PERSIST` | README.md §1 | SQLite 持久化,非目标 | 非目标 |
| `NG-LOCAL-VOLUME` | docs/deployment.md §1 | 本地卷持久化,非目标 | 非目标 | | `NG-LOCAL-VOLUME` | README.md §1 | 本地卷持久化,非目标 | 非目标 |
| `NG-SERVERLESS-BIND` | docs/deployment.md §1 | 绑定具体 serverless 平台,非目标 | 非目标 | | `NG-SERVERLESS-BIND` | README.md §1 | 绑定具体 serverless 平台,非目标 | 非目标 |
| `MOD-DEDUP` | docs/deployment.md §6 | Redis `SET NX` 幂等键 | 组件 | | `MOD-DEDUP` | README.md §6.1 | Redis `SET NX` 幂等键 | 组件 |
| `MOD-STREAMS` | docs/deployment.md §6 | Redis Streams 队列 + worker | 组件 | | `MOD-STREAMS` | README.md §6.1 | Redis Streams 队列 + worker | 组件 |
| `MOD-SINCESTATE` | docs/deployment.md §6 | sinceState 游标(存 Redis) | 组件 | | `MOD-SINCESTATE` | README.md §6.1 | sinceState 游标(存 Redis) | 组件 |
| `FLOW-NEW-MAIL` | docs/design.md §5.4 / docs/deployment.md §6 | Push 新邮件流 | 数据流 | | `FLOW-NEW-MAIL` | docs/design.md §5.4 | Push 新邮件流 | 数据流 |
| `FLOW-RECONCILE` | docs/deployment.md §6 | 外部 Cron 对账补差 + Redis 丢失恢复 | 数据流 | | `FLOW-RECONCILE` | README.md §3.10 | 外部 Cron 对账补差 + Redis 丢失恢复 | 数据流 |
| `REQ-AI-CONFIRM` | docs/design.md §12 | AI 仅在明确要求+确认后接触正文 | 需求 | | `REQ-AI-CONFIRM` | docs/design.md §12 | AI 仅在明确要求+确认后接触正文 | 需求 |
| `REQ-VIEW-DIRECT` | docs/design.md §12 | 查看原文始终 JMAP 直取 | 需求 | | `REQ-VIEW-DIRECT` | docs/design.md §12 | 查看原文始终 JMAP 直取 | 需求 |
| `REQ-LONG-EMAIL` | docs/design.md §12 | 长邮件禁止发全文,AI 摘要 ~300 字 | 需求 | | `REQ-LONG-EMAIL` | docs/design.md §12 | 长邮件禁止发全文,AI 摘要 ~300 字 | 需求 |
@ -239,8 +238,8 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
| `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:110 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 | | `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:110 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 |
| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 | | `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 |
| `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | 通知的收件时间按业务配置 `timezone`(IANA,默认 `Asia/Shanghai`)渲染为 `%Y-%m-%d %H:%M`;仅接受 16 个无夏令时区域,未匹配返回 422,不做时区库推断 | 需求 | | `REQ-TIMEZONE-DISPLAY` | src/config.rs `SUPPORTED_TIMEZONES` / docs/reference.md §5.4 | 通知的收件时间按业务配置 `timezone`(IANA,默认 `Asia/Shanghai`)渲染为 `%Y-%m-%d %H:%M`;仅接受 16 个无夏令时区域,未匹配返回 422,不做时区库推断 | 需求 |
| `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 | | `NFR-NOTIFY-SLA` | README.md §6.3 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 |
| `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 | | `NFR-RECONCILE-INTERVAL` | README.md §3.10 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 |
| `SAF-NOTIFY-META` | §3 | 新邮件通知只含元数据,正文不入通知 | 安全 | | `SAF-NOTIFY-META` | §3 | 新邮件通知只含元数据,正文不入通知 | 安全 |
| `SAF-CHAT-ALLOWLIST` | §3 / docs/design.md §7.3 | CHAT_ALLOWLIST 硬约束,处理前先拒绝非白名单 | 安全 | | `SAF-CHAT-ALLOWLIST` | §3 / docs/design.md §7.3 | CHAT_ALLOWLIST 硬约束,处理前先拒绝非白名单 | 安全 |
| `SAF-AUTH-RECONCILE` | §3 / docs/design.md §7.3 | `/reconcile` 需 `Authorization: Bearer RECONCILE_TOKEN`,fail-closed | 安全 | | `SAF-AUTH-RECONCILE` | §3 / docs/design.md §7.3 | `/reconcile` 需 `Authorization: Bearer RECONCILE_TOKEN`,fail-closed | 安全 |
@ -265,27 +264,27 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
| `SAF-JMAP-URL` | docs/design.md §7.1 / §4 | JMAP URL 约束:仅 HTTPS、禁止内嵌凭据、拒绝危险 query | 安全 | | `SAF-JMAP-URL` | docs/design.md §7.1 / §4 | JMAP URL 约束:仅 HTTPS、禁止内嵌凭据、拒绝危险 query | 安全 |
| `REQ-JMAP-RAW-MULTIPART` | docs/design.md §3.2/§10.1 | `read_email` 多 part 原文:按 text_body 顺序拼接"有 part_id 且 bodyValue"的部分;无可用部分→明确错误 | 需求 | | `REQ-JMAP-RAW-MULTIPART` | docs/design.md §3.2/§10.1 | `read_email` 多 part 原文:按 text_body 顺序拼接"有 part_id 且 bodyValue"的部分;无可用部分→明确错误 | 需求 |
| `GATE-G1-JMAP-READONLY` | docs/design.md §10.1 / §6 | G1 门禁:只读 adapter **代码已实现**(mock + `#[ignore]` 真机测试),**待真实 `cargo test -- --ignored jmap::` 验证** | 流程 | | `GATE-G1-JMAP-READONLY` | docs/design.md §10.1 / §6 | G1 门禁:只读 adapter **代码已实现**(mock + `#[ignore]` 真机测试),**待真实 `cargo test -- --ignored jmap::` 验证** | 流程 |
| `ARCH-LB-WORKER` | docs/deployment.md §10 | 多实例 LB/HA:免费套餐 Cloudflare Worker 作唯一*注册*入口 + 故障转移;后端仍可直接访问,负载均衡器不加访问控制 | 架构 | | `ARCH-LB-WORKER` | README.md §2 | 多实例 LB/HA:免费套餐 Cloudflare Worker 作唯一*注册*入口 + 故障转移;后端仍可直接访问,负载均衡器不加访问控制 | 架构 |
| `C-LB-SINGLE-REG-URL` | docs/deployment.md §10.1 | Telegram/Push/Cron 只登记 Worker 的稳定 URL;后端平台入口不对外登记 | 约束 | | `C-LB-SINGLE-REG-URL` | README.md §2 | Telegram/Push/Cron 只登记 Worker 的稳定 URL;后端平台入口不对外登记 | 约束 |
| `C-LB-SHARED-SECRETS` | docs/deployment.md §10.3 | 多实例必须共享同一组 `SAF-AUTH-*` secret,否则随机 401 | 约束 | | `C-LB-SHARED-SECRETS` | README.md §2 | 多实例必须共享同一组 `SAF-AUTH-*` secret,否则随机 401 | 约束 |
| `SAF-LB-PASSTHRU` | docs/deployment.md §10.3 / §3 | 信任模型=透传:Worker 不改写鉴权;后端必须继续 fail-closed 校验(后端可能被公网直连) | 安全 | | `SAF-LB-PASSTHRU` | §3 | 信任模型=透传:Worker 不改写鉴权;后端必须继续 fail-closed 校验(后端可能被公网直连) | 安全 |
| `SAF-RECONCILE-LOCK` | docs/deployment.md §10.5 | `/reconcile` 不扇出,Redis 锁保证单实例执行,避免重复对账 | 安全 | | `SAF-RECONCILE-LOCK` | README.md §6.1 | `/reconcile` 不扇出,Redis 锁保证单实例执行,避免重复对账 | 安全 |
| `MOD-STREAMS-GROUP` | docs/deployment.md §10.5 | 多实例用同一 Streams 消费组名,Redis 自动分摊(at-least-once 不重复处理) | 组件 | | `MOD-STREAMS-GROUP` | README.md §6.1 | 多实例用同一 Streams 消费组名,Redis 自动分摊(at-least-once 不重复处理) | 组件 |
| `MOD-HEALTH-AGG` | docs/deployment.md §10.6 | Worker 聚合健康视图,报告各后端存活供外部监控 | 组件 | | `MOD-HEALTH-AGG` | README.md §2 | Worker 聚合健康视图,报告各后端存活供外部监控 | 组件 |
| `MOD-DEBUG` | src/debug.rs / docs/deployment.md §2.1 / docs/design.md §7.6 / docs/reference.md §3 | 远程联调只读表面:`DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子开启后挂载 `/debug/*`,否则不挂载 | 组件 | | `MOD-DEBUG` | src/debug.rs / docs/design.md §7.6 / docs/reference.md §3 | 远程联调只读表面:`DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子开启后挂载 `/debug/*`,否则不挂载 | 组件 |
| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭。开启信号走 env 而非 argv,使启动命令保持静态、开关可在平台控制台单点切换 | 安全 | | `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 | 双因子门禁:「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭。开启信号走 env 而非 argv,使启动命令保持静态、开关可在平台控制台单点切换 | 安全 |
| `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | | `SAF-DEBUG-AUTH` | src/debug.rs | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 |
| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 | | `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 |
| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在负载均衡器 19 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 | | `SAF-DEBUG-ORIGIN-ONLY` | docs/reference.md §4 | `/debug/*` 不在负载均衡器 19 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 |
| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 | | `SAF-DEBUG-ALLOWLIST` | src/debug.rs | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 |
| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 | | `NFR-HA-MULTI-INSTANCE` | README.md §6.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 |
| `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 | | `C-NO-DB` | §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 |
| `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / §3 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 | | `C-NO-LOCAL-WRITE` | §3 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 |
| `C-LOG-STDOUT-ONLY` | docs/deployment.md §0 / §9.1 / §3 | 日志只写 stdout/stderr,由平台采集;禁用文件日志后端 | 约束 | | `C-LOG-STDOUT-ONLY` | §3 | 日志只写 stdout/stderr,由平台采集;禁用文件日志后端 | 约束 |
| `SAF-LOG-PURITY` | docs/deployment.md §0 / §9.1 / §3 | 日志与 Redis 写入内容仅限结构化事件/计数/时间戳/脱敏摘要;禁止密钥/邮件正文/AI 请求响应/附件内容 | 安全 | | `SAF-LOG-PURITY` | §3 | 日志与 Redis 写入内容仅限结构化事件/计数/时间戳/脱敏摘要;禁止密钥/邮件正文/AI 请求响应/附件内容 | 安全 |
| `SAF-ENABLE-FLAG` | src/notify.rs `put_enabled` / src/state.rs `config:enabled` | 全局开关是 Redis 单键 `config:enabled`;未写入即视为关闭,`business_enabled` 出错也按关闭处理(fail-closed);写入须 admin-session Bearer | 安全 | | `SAF-ENABLE-FLAG` | src/notify.rs `put_enabled` / src/state.rs `config:enabled` | 全局开关是 Redis 单键 `config:enabled`;未写入即视为关闭,`business_enabled` 出错也按关闭处理(fail-closed);写入须 admin-session Bearer | 安全 |
| `C-NO-STATEFUL-RECOVERY` | docs/deployment.md §0 / §9.1 / §3 | 禁止依赖进程内状态做生产恢复;恢复一律走 Redis + JMAP 对账;进程内缓存仅为性能优化,丢失须安全可重入 | 约束 | | `C-NO-STATEFUL-RECOVERY` | §3 | 禁止依赖进程内状态做生产恢复;恢复一律走 Redis + JMAP 对账;进程内缓存仅为性能优化,丢失须安全可重入 | 约束 |
| `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis 为唯一状态来源 | 状态层统一走 Redis(Streams/SET NX/锁/摘要),进程不持有可恢复状态 | 架构 | | `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis 为唯一状态来源 | 状态层统一走 Redis(Streams/SET NX/锁/摘要),进程不持有可恢复状态 | 架构 |
| `C-REDIS-EXTERNAL` | docs/deployment.md §8.1 | Redis 由外部已认证实例提供,不与本服务同容器 | 部署约束 | | `C-REDIS-EXTERNAL` | README.md §3.0 | Redis 由外部已认证实例提供,不与本服务同容器 | 部署约束 |
| `GATE-UPTIME-KUMA` | docs/deployment.md §10.1 | `/healthz` 稳定语义可直接接 Uptime Kuma 等外部探针 | 门禁 | | `GATE-UPTIME-KUMA` | docs/reference.md §9.4 | `/healthz` 稳定语义可直接接 Uptime Kuma 等外部探针 | 门禁 |
| `GATE-DOCS` | scripts/docs_check/run_all.sh / docs/deployment.md | 文档门禁:校验器全部 0 error、exit 0 才算通过 | 门禁 | | `GATE-DOCS` | scripts/docs_check/run_all.sh | 文档门禁:校验器全部 0 error、exit 0 才算通过 | 门禁 |

View File

@ -1,299 +0,0 @@
# MessageWeave deployment guide
> [中文版本 / Chinese version → deployment.zh-CN.md](deployment.zh-CN.md)
Use this guide to deploy a backend, configure mail delivery, and verify notifications.
Choose any platform that can run the backend container, provide encrypted environment
secrets, and connect to an externally managed Redis service. The optional Cloudflare Worker
load balancer adds a stable URL in front of one or more backends. Platform-specific settings are
in the [deployment platform reference](reference.md#9-deployment-platform-details).
## 0. Before deployment
Prepare an externally managed Redis service with persistence enabled and a public HTTPS URL
that reaches the backend. The platform or proxy terminates TLS; MessageWeave listens on one
HTTP port (`PORT`, default `8080`). The process needs two encrypted boot secrets:
`REDIS_URL` and `CONFIG_ENCRYPTION_KEY` (a random 32-byte key encoded as 64 hexadecimal
characters). Never put real values in source control, an image, or logs. Business settings
are saved in Redis through the protected configuration API; adding them as environment
variables does not configure the service.
Production has no local database, data volume, or internal scheduler. Full security boundaries
and prohibited modes are in [`charter.md`](charter.md).
## 1. Deploy a backend
### Container platform
Build the root Dockerfile and configure the required secrets in the platform. For a local
container smoke run, copy `.env.example` to an untracked `.env`, replace both placeholders,
then run:
```sh
docker build -t messageweave:latest .
docker run --rm --env-file .env -p 8080:8080 messageweave:latest
```
The image uses a Rust builder and a Debian slim runtime, runs as a non-root user, and exposes
only port `8080`. Do not mount a data or log volume. Full image contents are in the
[platform reference](reference.md#91-backend-deployment-paths).
## 2. First-time configuration
Open the backend's configuration page at its origin for initial setup. Use
`CONFIG_ENCRYPTION_KEY` to establish the admin session, then complete the one-time bootstrap
form. The bootstrap route is not in the load balancer's route set, so it is only reachable at the
backend's own address. The one-time bootstrap and
admin-session handlers are `src/notify.rs:834` and `src/notify.rs:1187`; saving business
configuration uses `src/notify.rs:694`. Later edits can use the protected page through the Worker URL.
Configure at least the Telegram bot token, target chat ID, inbound chat allowlist, Stalwart
JMAP HTTPS session URL and application password, webhook secret, and worker token. Set a
reconcile token if using scheduled reconciliation. Configure LLM settings only when users
have explicitly consented to external processing. Secret values are not returned by the
configuration API; the full field semantics are in the [configuration reference](reference.md#5-environment-variables).
### 2.1 Optional remote diagnostics
Leave `DEBUG_ENABLED` and `DEBUG_TOKEN` unset in production unless remote diagnostics are
deliberately needed. Both must be configured to mount the diagnostic routes, and those routes
are reachable only through the backend origin. The diagnostic surface and response details
are recorded in the [reference](reference.md#3-backend-routes).
## 3. Build and run the Docker image
The root Dockerfile is the container deployment path. It has separate Rust builder and
Debian slim runtime stages; see [§8.2](#82-docker-image-details) for the runtime contents.
## 4.1 Register Telegram and Stalwart callbacks
After saving business configuration, use the **Register Telegram and Stalwart callbacks**
section in the configuration page. Enter the public HTTPS origin that receives callbacks:
the Worker URL when the load balancer is enabled, otherwise the backend origin. The page registers
`/webhook/tg` with Telegram and `/push/jmap` with Stalwart. Credentials remain in the backend;
the browser sends only the callback URLs. The protected registration handlers are
`src/notify.rs:1623` for Telegram and `src/notify.rs:1468` for Stalwart.
Telegram's secret token and `allowed_updates: ["message"]` are taken from the saved business
configuration. Stalwart verification and verification-code writeback happen automatically
after registration. Registration can be repeated safely; registering the same origin is
idempotent. Changing the callback origin updates Telegram (atomic replacement) and, for
Stalwart, destroys the previous subscription before creating the new one under the registration
lock, so the old origin stops receiving pushes.
To remove a subscription, call `POST /api/push/disable` with the same `callback_url`.
## 5. Runtime configuration
Keep boot-time process configuration separate from Redis-resident business configuration.
Only `REDIS_URL` and `CONFIG_ENCRYPTION_KEY` are required at process startup. The full
business field list is in the [configuration reference](reference.md#5-environment-variables).
## 6. Short-request runtime and scheduling
The service handles one request at a time and does not keep JMAP event streams, Telegram
long polling, or a background timer open. Telegram/JMAP push provide the fast path; the
external scheduler invokes the endpoints described below.
### 6.3 Schedule queue draining and reconciliation
The backend has no internal scheduler. An external cron, scheduled job, or platform timer
must call [`scripts/cron-drain.sh`](../scripts/cron-drain.sh); otherwise queued notifications
remain undelivered. The script calls `/reconcile` (`src/notify.rs:268`) when
`MW_RECONCILE_TOKEN` is set, then drains the worker queue through `/worker`
(`src/notify.rs:390`).
Provide `MW_APP_URL` and `MW_WORKER_TOKEN`; set `MW_RECONCILE_TOKEN` when reconciliation is
enabled. Store tokens in the scheduler's secret manager. For a one-shot run:
With the tokens injected by the scheduler's secret manager, run:
```sh
MW_APP_URL='https://<PUBLIC_URL>' bash scripts/cron-drain.sh --once
```
The reconciliation cadence is normally 5–10 minutes. Both successful endpoints return `204`
with an empty body; do not treat that as an error. The script's `--diagnose` mode helps
separate service configuration problems from scheduling problems. Its arguments and
operating modes are listed in the [scheduler reference](reference.md#93-callback-registration-and-scheduled-work).
### 6.4 Reliability behavior
The Worker retries only timeouts and backend `5xx` responses; it returns `4xx` responses
directly. `/reconcile` and `/worker` have longer per-route timeouts and do not fail over to a
second backend, avoiding duplicate work. Backend queue processing is at least once and relies
on Redis-backed idempotency. See the route and state reference for the complete behavior.
### 6.5 Delivery objective
The service permits short notification delays and targets at least 99.9% notification
availability. Redis availability and the external scheduling interval are outside the
application's control; exact delivery guarantees are described in `NFR-NOTIFY-SLA` and
`NFR-RECONCILE-INTERVAL` in the project charter.
## 7. Health checks
Use `/healthz` for liveness (`src/notify.rs:118`) and `/ready` for dependency readiness
(`src/notify.rs:193`). The verification sequence is in [§8.1](#81-verify-the-deployment).
## 8.1 Verify the deployment
Check the backend first:
| Request | Expected result | Meaning |
|---|---|---|
| `GET /healthz` | `200` | The process is alive. |
| `GET /ready` | `200` | Configuration, Redis, JMAP, and Telegram probes are ready; dependency failures return `503`. |
| `GET /api/status` | `ready: true` | Required boot settings are present; `missing` identifies absent names. |
When the Worker load balancer is enabled, also request `GET /healthz-worker`. Require
`available >= 1` and check that `version` matches the deployed `LB_VERSION`; HTTP 200 alone
can mean `no-backends`. The `cloudflare-worker/` directory's tests and deploy command are
described in its README.
Send `/help` to the configured Telegram chat and confirm a reply. Then send a new email and
confirm that the external scheduler drains its notification. A healthy `/healthz` only proves
process liveness; it does not prove that Redis or either upstream is usable.
## 8.2 Docker image details
The root Dockerfile's builder uses `rust:1-slim-bookworm`; the runtime is
`debian:bookworm-slim`. The runtime installs CA certificates, `tini`, `curl`, `procps`,
`iproute2`, `jq`, and `netcat-openbsd`, creates the `messageweave` system user, sets
`PORT=8080`, and starts the binary through `tini`. It has no `VOLUME` declaration and does
not start Redis. These details describe the repository Docker image; other platforms may provide their own
builder and runtime image.
## 9. Confirmed deployment choices
These deployment decisions are settled; they are not per-installation prerequisites.
### 9.1 Public ingress and state
The backend uses an external HTTPS ingress and external Redis. Telegram, JMAP Push, and
scheduled requests use the selected public URL. When the Worker load balancer is enabled, register
that stable Worker URL with Telegram and Stalwart; do not expose credentials at the load balancer.
The stable IDs in this section point to the project charter's registry.
## 10. Optional Cloudflare Worker load balancer
The load balancer is optional; nothing in the backend requires it. It provides one public HTTPS
entry point for multiple backend origins. All backends must share the same Redis and business
configuration. Detailed route facts are in the
[load balancer route matrix](reference.md#4-load-balancer-vs-backend-route-matrix).
Its purpose is load balancing, not protection. Cloudflare's Load Balancer product is not
available on the free plan, and this is how a free-plan account puts load sharing and failover
in front of several origins. There is no intent to hide the backends and no intent to harden
them: the origins are directly reachable whether the load balancer is present or not, and no access
control is added at the edge. The fixed route allowlist is what bounds the paths the balancer
forwards — bootstrap and `/debug/*` are not in it, so requests addressed to the load balancer never
reach them — but it is not a firewall.
### 10.1 Topology
Telegram webhooks, Stalwart push callbacks, and external scheduling can use the stable Worker
URL. The Worker forwards to backend HTTPS origins; it does not connect to Redis or JMAP.
### 10.2 Multi-instance prerequisites
Multiple backends can share traffic because request state is stored in Redis, duplicate
deliveries are deduplicated, and reconciliation repairs missed push events. No sticky session
or application code change is required.
All Redis keys are named globally and carry no instance, hostname, or origin suffix. Sharing one
Redis is not a compromise here: several origins hold a single copy of each item below.
| Data | Key | Semantics |
|---|---|---|
| Business configuration | `config:business` | one global ciphertext blob (AES-256-GCM), with no per-instance slot |
| Configuration revision | `config:business:revision` | one global monotonic counter, bumped in the same Lua transaction as the write (`CONFIG_STORE_REV_SCRIPT`) |
| Enable gate | `config:enabled` | one global boolean |
| Reconcile cursor | `state:jmap:since` | one global cursor, with no account dimension |
| Delivery streams | `stalwart:jmap:events` / `stalwart:telegram:events` | shared through a consumer group; the group is created lazily and `XAUTOCLAIM` reclaims idle messages |
| Reconcile lock | `lock:reconcile` | one global single-flight lock, mutual exclusion across instances |
Two consequences matter in practice.
First, **the business configuration exists once, and an SPA edit from any origin writes the same
copy.** There is no way to change only one instance: no key carries an instance dimension and
`refresh_business_config` takes no instance argument. This is intended behavior. If two genuinely
independent configurations are needed, for example production alongside a pre-production twin,
deploy a second instance pointed at a second Redis rather than distinguishing inside one Redis.
Second, **`CONFIG_ENCRYPTION_KEY` must be byte-identical on every origin.** It is 64 hex
characters (32 bytes) and is used directly as the AES-256-GCM key, with the AAD fixed at
`message-weave/config:business/v1`. It also doubles as the SPA admin credential. An origin holding
a different value can neither decrypt configuration written by another origin nor log in to the
SPA, and both paths fail closed.
Extra origins buy failover rather than throughput. Delivery is spread across the shared consumer
group, but `/reconcile` is serialized globally by `lock:reconcile` and the cursor is singular. If
one instance dies mid-batch, the next instance picks the messages up once they pass the
`XAUTOCLAIM` idle window; the cost is possible duplicate delivery, guarded by the `dedup:*` keys,
never message loss.
### 10.3 Trust model
The Worker passes request headers and bodies through. Backend authentication remains required
because backend origins may be directly reachable. Every instance must use the same shared
business credentials and Redis state.
### 10.4 Routes and failover
HTTP requests to the Worker receive a permanent `308` redirect to the same HTTPS URL before
route validation or forwarding. The Worker rejects unregistered routes with `404` and wrong
methods with `405`. Ordinary requests try at most `LB_MAX_ATTEMPTS` origins (default two), and
only a timeout or `5xx` triggers failover. `/reconcile` and `/worker` are single-attempt routes with longer timeout
overrides. Use the route matrix and tuning values in the [reference](reference.md#4-load-balancer-vs-backend-route-matrix).
### 10.5 Callback URLs
When using the load balancer, set Telegram's webhook and Stalwart's push callback to the Worker
domain. The external scheduler can use the same URL for forwarded paths. Bootstrap and
diagnostic endpoints still require the backend origin.
### 10.6 Load balancer health
`GET /healthz-worker` reports the Worker version and backend availability. Use both
`available` and `version` to verify a deployment; `status: no-backends` can be returned with
HTTP 200 when no valid origin is configured.
### 10.7 Boundaries
The Worker is not a Redis proxy and does not make the backend stateless; shared external Redis
remains required. It does not add a database, a long-lived connection, or a second layer of
business authentication.
### 10.8 Configuration convergence across origins
One SPA edit writes once to Redis. The origins converge **lazily, at the request boundary**: each
entry point that calls `refresh_business_config` re-reads the global revision from Redis and
returns immediately when `remote_revision <= local_revision`. There is no polling loop and no
watch, so an origin that receives no traffic keeps the configuration it already had.
The entry points are `/ready`, `/worker`, `/reconcile`, `/push/jmap`, `/webhook/tg`,
`/api/config`, `/api/business-config` and `/api/telegram/register-webhook`. `GET /healthz` is
**not** among them — it is a static `200 "ok"` and never refreshes — and neither does
`GET /api/status`, `/api/enabled`, `/api/push/register`, `/api/push/disable` or the
`/api/admin/session` pair.
**Known behavior: a failed rebuild is not retried until the next revision.** The revision check
above is an early exit, not a retry guard. When `build_worker` fails for one origin — a JMAP
session URL that is unreachable, or an `llm_api_key` / `llm_base_url` that is missing or invalid —
that origin advances its in-memory revision to the remote value and keeps the runtime it already
had. The early-exit condition then holds forever for that origin, so it does not try again until a
**new** revision is written. Every other failure inside the same function (Redis unreachable, blob
missing, JSON parse failure, validation failure) leaves the local revision untouched and therefore
does retry on the next request; those paths fail before a runtime is built, so no worker is
rebuilt.
The symptom is one origin running the new configuration and another still on the old one, with no
error and no log line: the failed path returns silently. To detect it, compare the field values
reported by `GET /debug/config` on each origin — those come from the live in-memory runtime. Do
not use its `revision` field for this: that value is read from Redis and shows the latest global
revision even when the instance has not converged.
To recover, touch the configuration again from the SPA so that the revision moves; the stuck
origin will attempt the rebuild once. If the origin is still in the Worker's origin list, remove
it in the meantime so requests are not split between two configurations.

View File

@ -1,264 +0,0 @@
# MessageWeave 部署指南
> [English version / 英文版 → deployment.md](deployment.md)
使用本指南部署后端、配置邮件传递并验证通知。
选择任何可以运行后端容器的平台,提供加密环境
密钥,并连接到外部管理的 Redis 服务。可选的 Cloudflare Worker
负载均衡器 在一个或多个后端前面添加一个稳定的 URL。特定于平台的设置是
在[部署平台参考](reference.zh-CN.md#9-部署平台细节)中。
## 0. 部署前准备
准备启用持久性和公共 HTTPS URL 的外部管理的 Redis 服务
到达后端。平台或代理终止 TLS; MessageWeave 监听其中一个
HTTP 端口(“PORT”,默认“8080”)。该过程需要两个加密的启动密钥:
`REDIS_URL` 和 `CONFIG_ENCRYPTION_KEY` (编码为 64 十六进制的随机 32 字节密钥
字符)。切勿将真实值放入源代码管理、图像或日志中。业务设置
通过受保护的配置API保存在Redis中;将它们添加为环境
变量不配置服务。
生产环境没有本地数据库、数据量或内部调度程序。完整的安全边界
禁止模式位于 [`charter.md`](charter.zh-CN.md) 中。
## 1. 部署后端
### 容器平台
使用仓库根目录的 Dockerfile 构建镜像,并在部署平台配置必需的密钥。
如需进行本地容器冒烟测试,将 `.env.example` 复制为未跟踪的 `.env`,替换两个占位值后运行:
```sh
docker build -t messageweave:latest .
docker run --rm --env-file .env -p 8080:8080 messageweave:latest
```
该镜像使用 Rust 构建器和 Debian slim 运行时,以非 root 用户身份运行,并公开
仅端口“8080”。不要安装数据或日志卷。完整图像内容位于
[平台参考](reference.zh-CN.md#91-后端部署方式)。
## 2. 首次配置
在原点打开后端的配置页面进行初始设置。使用
`CONFIG_ENCRYPTION_KEY` 建立管理会话,然后完成一次性引导
表单。bootstrap 路由不在负载均衡器路由集内,因此只能在后端自己的地址上访问。bootstrap 和管理会话处理器位于
`src/notify.rs:834` 与 `src/notify.rs:1187`;业务配置保存处理器位于
`src/notify.rs:694`。后续修改可通过 Worker URL 打开受保护的配置页。
至少配置 Telegram 机器人令牌、目标聊天 ID、入站聊天允许列表、Stalwart
JMAP HTTPS 会话 URL 和应用专用密码、Webhook 密钥和工作令牌。如果启用定时对账,还需设置对账令牌。
只有用户明确同意外部处理时,才配置 LLM。配置 API 不会返回密钥内容;完整字段语义见
[配置参考](reference.zh-CN.md#5-环境变量)。
### 2.1 可选的远程诊断
在生产中保留“DEBUG_ENABLED”和“DEBUG_TOKEN”未设置,除非远程诊断
刻意需要的。两者都必须配置为挂载诊断路由,并且这些路由
只能通过后端源访问。诊断表面和响应详细信息
记录在[参考](reference.zh-CN.md#3-后端路由)中。
## 3. 构建并运行 Docker 镜像
根 Dockerfile 是容器部署路径。它有独立的 Rust 构建器和
Debian slim 运行时阶段;有关运行时内容,请参阅 [§8.2](#82-docker-镜像细节)。
## 4.1 注册 Telegram 和 Stalwart 回调
保存业务配置后,在配置页的“注册 Telegram 和 Stalwart 回调”区域操作。输入接收回调的公开 HTTPS origin:启用 Worker 时填写 Worker 地址,否则填写后端地址。页面会向 Telegram 注册 `/webhook/tg`,并向 Stalwart 注册 `/push/jmap`。凭据保留在后端,浏览器只提交回调 URL。Telegram 和 Stalwart 注册处理器分别位于 `src/notify.rs:1623` 与 `src/notify.rs:1468`。
Telegram 使用已保存的 webhook secret 和 `allowed_updates: ["message"]`。注册后,Stalwart 会自动完成验证回调和 verification-code 写回。可以安全地重复注册,重复注册同一 origin 是幂等的。更改回调 origin 会更新 Telegram(原子替换),对 Stalwart 则会在注册锁内先销毁旧订阅、再创建新订阅,因此旧 origin 不再收到推送。
## 5. 运行时配置
将启动时进程配置与 Redis 驻留业务配置分开。
进程启动时仅需要“REDIS_URL”和“CONFIG_ENCRYPTION_KEY”。完整的
业务字段列表在【配置参考】(reference.md#5-environment-variables)中。
## 6. 短请求运行模式与任务调度
该服务一次处理一个请求,并且不保留 JMAP 事件流、Telegram
长轮询,或打开后台计时器。 Telegram/JMAP 推送提供快捷路径;的
外部调度程序调用下面描述的端点。
### 6.3 调度队列排空与状态协调
后端没有内部调度程序。外部 cron、计划任务或平台定时器必须调用
[`scripts/cron-drain.sh`](../scripts/cron-drain.sh),否则排队的通知会一直无法投递。
设置了 `MW_RECONCILE_TOKEN` 时,脚本先调用 `/reconcile`,然后排空 `/worker` 队列。
提供 `MW_APP_URL` 和 `MW_WORKER_TOKEN`;启用增量对账时还要设置 `MW_RECONCILE_TOKEN`。
将这些令牌存入调度器的密钥管理器。单次运行时,使用调度器注入的令牌:
```sh
MW_APP_URL='https://<PUBLIC_URL>' bash scripts/cron-drain.sh --once
```
增量对账通常每 5–10 分钟运行一次。两个端点成功时都返回 `204`,且响应正文为空;这是正常结果,不是错误。
脚本的 `--diagnose` 模式可帮助区分服务配置问题和调度问题。参数与运行模式见
[调度器参考](reference.zh-CN.md#93-回调注册与定时任务)。
### 6.4 可靠性行为
Worker 仅重试超时和后端“5xx”响应;它返回“4xx”响应
直接。 `/reconcile` 和 `/worker` 具有更长的每条路由超时,并且不会故障转移到
第二个后端,避免重复工作。后端队列处理至少一次并且依赖
关于 Redis 支持的幂等性。请参阅路线和状态参考以了解完整的行为。
### 6.5 投递目标
该服务允许短暂的通知延迟,并以至少 99.9% 的通知率为目标
可用性。 Redis 可用性和外部调度间隔超出范围
应用程序的控制;准确的交付保证在“NFR-NOTIFY-SLA”中进行了描述,并且
项目章程中的“NFR-RECONCILE-INTERVAL”。
## 7. 健康检查
使用 `/healthz` 检查进程存活(`src/notify.rs:118`),使用 `/ready` 检查依赖就绪
(`src/notify.rs:193`)。验证步骤见[§8.1](#81-验证部署)。
## 8.1 验证部署
首先检查后端:
|请求|预期结果|含义|
|---|---|---|
|`GET /healthz`|`200`|进程存活。|
|`GET /ready`|`200`|配置、Redis、JMAP 和 Telegram 探针均就绪;依赖故障返回 `503`。|
|`GET /api/status`|`{"ready":true}`|必需的启动配置已就绪;`missing` 字段列出缺失变量名。|
启用 Worker 负载均衡器后,还请求“GET /healthz-worker”。要求
`available >= 1` 并检查 `version` 是否与部署的 `LB_VERSION` 匹配;仅 HTTP 200
可能意味着“无后端”。 `cloudflare-worker/` 目录的测试和部署命令是
在其自述文件中描述。
将“/help”发送到配置的 Telegram 聊天并确认回复。然后发送一封新电子邮件并
确认外部调度程序耗尽其通知。健康的“/healthz”只能证明
进程活跃度;它并不能证明 Redis 或上游任一者可用。
## 8.2 Docker 镜像细节
根 Dockerfile 的构建器使用 `rust:1-slim-bookworm`;运行时间是
`debian:bookworm-slim`。运行时安装 CA 证书、`tini`、`curl`、`procps`、
`iproute2`、`jq` 和 `netcat-openbsd` 创建 `messageweave` 系统用户,设置
`PORT=8080`,并通过 `tini` 启动二进制文件。它没有“VOLUME”声明并且确实
不启动Redis。这些信息描述仓库提供的 Docker 镜像;其他平台可以使用自己的构建器和运行时镜像。
## 9. 已确认的部署选择
这些部署决定已经确定;它们不是每次安装的先决条件。
### 9.1 公共入口与状态存储
后端使用外部 HTTPS 入口和外部 Redis。 Telegram、JMAP 推送和
计划的请求使用选定的公共 URL。当Worker负载均衡器启用后,注册
Telegram 和 Stalwart 的稳定 Worker URL;不要在负载均衡器处公开凭据。
本节中的稳定 ID 指向项目章程的注册表。
## 10. 可选的 Cloudflare Worker 负载均衡器
负载均衡器是可选项,后端没有任何东西依赖它。它为多个后端 origin
提供一个公共 HTTPS 入口。所有后端必须共享同一 Redis 和业务配置。
详细的路由事实在[负载均衡器路由矩阵](reference.zh-CN.md#4-负载均衡器与后端路由对照表)中。
它的用途是负载均衡,不是防护。Cloudflare 的 Load Balancer 产品在免费
套餐上不可用,这就是免费套餐账户在多个 origin 前面做负载均衡与故障转移
的办法。没有隐藏后端的意图,也没有加固后端的意图:负载均衡器在不在,origin
都同样可以直接访问,边缘上也没有加任何访问控制。固定的路由白名单只是
限定负载均衡器转发哪些路径——bootstrap 和 `/debug/*` 不在其中,所以发给
负载均衡器的请求到不了它们——但它不是防火墙。
### 10.1 部署拓扑
Telegram webhooks、Stalwart 推送回调和外部调度可以使用稳定的 Worker
网址。 Worker 转发到后端 HTTPS 源;它不连接到 Redis 或 JMAP。
### 10.2 多实例前提
多个后端可以共享流量,因为请求状态存储在 Redis 中,重复
交付已进行重复数据删除,协调修复了错过的推送事件。无粘性会话
或需要更改应用程序代码。
所有 Redis 键名都是全局命名的,**不带实例、主机名或源站后缀**。共享同一个 Redis 在这里不是
折中:下列每一项在多源站之间都只有一份。
| 数据 | 键 | 语义 |
|---|---|---|
| 业务配置 | `config:business` | 单一全局密文块(AES-256-GCM),没有按实例划分的槽位 |
| 配置版本 | `config:business:revision` | 单一全局单调计数器,与写入在同一次 Lua 事务中提交(`CONFIG_STORE_REV_SCRIPT`) |
| 启停门 | `config:enabled` | 一个全局布尔值 |
| 对账游标 | `state:jmap:since` | 单一全局游标,无账户维度 |
| 投递流 | `stalwart:jmap:events` / `stalwart:telegram:events` | 由消费者组共享,组惰性创建,`XAUTOCLAIM` 回收空闲消息 |
| 对账锁 | `lock:reconcile` | 单一全局单飞锁,跨实例互斥 |
由此有两条必须知道的推论。
第一,**业务配置只有一份,从任何一台源站的 SPA 编辑写进去的都是同一份**。不存在“只改其中一台”
的表达方式:没有任何键带实例维度,`refresh_business_config` 也不接收实例参数。这是预期行为。
如果确实需要两套互不影响的配置(例如生产与预发并行),正确做法是部署第二个实例并指向另一个
Redis,而不是在同一个 Redis 内部做区分。
第二,**`CONFIG_ENCRYPTION_KEY` 必须逐字一致**。它是 64 个十六进制字符(32 字节),直接作为
AES-256-GCM 密钥,AAD 固定为 `message-weave/config:business/v1`;它同时也是 SPA 的管理凭据。
任一台源站持有不同的值,既解不出别的源站写入的配置,也登录不了 SPA,两条路都是 fail closed。
加源站买到的是故障转移,不是吞吐。投递靠共享消费者组分摊,但 `/reconcile` 受 `lock:reconcile`
全局串行,游标也只有一份。一台实例在批次中间挂掉,消息会在 `XAUTOCLAIM` 空闲窗口过后被另一台
接手;代价是可能重复投递(由 `dedup:*` 键去重),不会丢消息。
### 10.3 信任模型
Worker 传递请求标头和正文。仍需要后端身份验证
因为后端源可能是直接可达的。每个实例必须使用相同的共享
业务凭证和 Redis 状态。
### 10.4 路由与故障转移
HTTP 请求访问 Worker 时,会先收到永久的 `308` 重定向,前往路径和查询参数相同的
HTTPS URL,之后才进行路由校验或转发。Worker 使用“404”拒绝未注册的路由,并使用“405”拒绝错误的方法。普通
请求最多尝试“LB_MAX_ATTEMPTS”来源(默认两个),并且只有超时或“5xx”
触发故障转移。 `/reconcile` 和 `/worker` 是具有较长超时时间的单次尝试路由
覆盖。使用[参考](reference.zh-CN.md#4-负载均衡器与后端路由对照表)中的路由矩阵和调整值。
### 10.5 回调地址
使用负载均衡器时,将 Telegram 的 webhook 和 Stalwart 的 push 回调都设置为 Worker URL。
外部调度程序(cron/`systemd`/K8s CronJob)对已转发的路径使用相同的 URL。
引导程序必须在后端源上执行;诊断端点需要后端源**和** debug token——Worker 都不转发它们。
### 10.6 负载均衡器健康状态
`GET /healthz-worker` 报告 Worker 的版本(`version`)和健康的 origin 数量
(`available`)。两者都用于验证部署:`version` 告诉你当前是哪次部署在应答,
`available` 告诉你有多少 origin 健康。`status: no-backends` 在返回 HTTP 200 时
是**正确**的。
Worker 不是 Redis 代理,也不会使后端无状态;共享外部
Redis 仍然需要。它不添加数据库、长期连接或第二层
业务认证。
### 10.8 多源站之间的配置收敛
SPA 的一次编辑只写入 Redis 一次。各源站**惰性收敛,发生在请求边界上**:每个调用
`refresh_business_config` 的入口都会先从 Redis 读全局版本,当 `remote_revision <= local_revision`
时立即返回。没有轮询循环,也没有 watch,所以一台完全收不到流量的源站会一直保留它原有的配置。
会调用的入口是 `/ready`、`/worker`、`/reconcile`、`/push/jmap`、`/webhook/tg`、
`/api/config`、`/api/business-config`、`/api/telegram/register-webhook`。`GET /healthz`
**不**在其中——它只是一个静态的 `200 "ok"`,从不刷新;`GET /api/status`、`/api/enabled`、
`/api/push/register`、`/api/push/disable` 以及 `/api/admin/session` 那一对也不在其中。
**已知行为:重建失败后不会重试,直到下一次版本更新。** 上面的版本判断只是一个提前返回,它不是
重试保护。当某台源站的 `build_worker` 失败——JMAP session URL 不可达,或 `llm_api_key` /
`llm_base_url` 缺失或非法——该源站会把内存中的版本推进到远端值,并保留原有运行时。此后提前返回
条件对该源站永久成立,它不会再尝试,直到写入了一个**新的**版本。同一函数里的其他失败(Redis
不可达、密文块缺失、JSON 解析失败、校验失败)都不推进本地版本,因此会在下一次请求时重试;这些
路径都在构建运行时之前就返回,所以不会重建 worker。
症状是一台源站已运行新配置、另一台还停在旧配置上,而且**没有报错也没有日志**:失败路径是静默
返回的。排查方法是逐台比对 `GET /debug/config` 返回的字段值——那些值来自内存里的实时运行时。
不要用它的 `revision` 字段判断:那个值从 Redis 读取,即使该实例尚未收敛也会显示最新的全局版本。
恢复办法:在 SPA 里再动一次配置让版本前进,卡住的源站就会重新尝试一次。如果这台源站仍在
Worker 的源站列表里,这期间把它从列表中移除,避免请求被分裂到两套配置上。

View File

@ -10,9 +10,9 @@
> **Related documents** (separation of responsibilities to avoid duplication): > **Related documents** (separation of responsibilities to avoid duplication):
> - [docs/charter.md](charter.md) — Project charter: project goals, technology selection, security invariants, implementation phases, prohibited matters, test acceptance and stable ID registry > - [docs/charter.md](charter.md) — Project charter: project goals, technology selection, security invariants, implementation phases, prohibited matters, test acceptance and stable ID registry
> - [AGENTS.md](../AGENTS.md) — Universal, language-independent code writing and environment building specifications (excluding project-specific content) > - [AGENTS.md](../AGENTS.md) — Universal, language-independent code writing and environment building specifications (excluding project-specific content)
> - [docs/deployment.md](deployment.md) — Deployment, operation, maintenance and release: general HTTPS-only Docker container, Secrets, Redis (the only carrier of status), short request Webhook/Push/reconciliation routing, health check, CI release and items that still need to be confirmed > - [README.md](../README.md) — Deployment, operation and maintenance: HTTPS-only Docker, secrets, Redis (the only state carrier), short-request webhook/push/reconciliation routing, health checks, multi-instance operation
> >
> This document only retains content directly related to **product behavior, code architecture, module interfaces, state machines, data flow, error handling, testing and implementation phases**. Deployment operations and Docker/universal container platform details have been moved to `deployment.md`; project constraints and security invariants are in `docs/charter.md`; general, language-agnostic code writing and environment building specifications are in `../AGENTS.md`. > This document only retains content directly related to **product behavior, code architecture, module interfaces, state machines, data flow, error handling, testing and implementation phases**. Deployment operations and Docker/universal container platform details have been moved to [README.md §6](../README.md); project constraints and security invariants are in `docs/charter.md`; general, language-agnostic code writing and environment building specifications are in `../AGENTS.md`.
--- ---
@ -31,7 +31,7 @@
11. [Historical issues and decision-making archives (product/architecture category, both have been concluded)] (#11-Historical issues and decision-making archives, product architecture category have been concluded) 11. [Historical issues and decision-making archives (product/architecture category, both have been concluded)] (#11-Historical issues and decision-making archives, product architecture category have been concluded)
12. [AI auxiliary capabilities: architecture, confirmation threshold, failure fallback] (#12-ai-auxiliary capability architecture confirmation threshold failure fallback) 12. [AI auxiliary capabilities: architecture, confirmation threshold, failure fallback] (#12-ai-auxiliary capability architecture confirmation threshold failure fallback)
> Deployment/platform decisions confirmed (Single Account, App Password+Basic, Redis Hosting+AOF, Platform HTTPS URL, External Cron) and **all converged archive** (including `Q-DEP-A`/`Q-DEP-B`, see the Confirmed Decisions section of `docs/deployment.md`); **Unfinished code gaps can be found in `docs/opengaps.md`**. > Deployment/platform decisions confirmed (Single Account, App Password+Basic, Redis Hosting+AOF, Platform HTTPS URL, External Cron) and **all converged archive** (including `Q-DEP-A`/`Q-DEP-B`, see the Confirmed Decisions section of `docs/charter.md` §8); **Unfinished code gaps can be found in `docs/opengaps.md`**.
--- ---
@ -41,7 +41,7 @@
Build a Telegram robot written in Rust as a **personal email assistant** for Stalwart JMAP mailbox: Build a Telegram robot written in Rust as a **personal email assistant** for Stalwart JMAP mailbox:
- Query/read emails, view folders, send emails, manage keywords, etc. through Telegram commands. - Query/read emails, view folders, send emails, manage keywords, etc. through Telegram commands.
- Leverage JMAP's **Push HTTPS callback** (+ external Cron reconciliation) to **proactively push to Telegram** when new emails arrive. (EventSource/SSE/long polling is non-target, see deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`.) - Leverage JMAP's **Push HTTPS callback** (+ external Cron reconciliation) to **proactively push to Telegram** when new emails arrive. (EventSource/SSE/long polling is non-target, see deployment.md ``NG-POLLING-SSE``/`NG-LONG-POLLING`/`C-NO-LONG-CONN`.)
- Single-user or multi-account deployment (the default is for single-account self-hosting scenarios). - Single-user or multi-account deployment (the default is for single-account self-hosting scenarios).
### 1.2 Scope (this stage) ### 1.2 Scope (this stage)
@ -99,8 +99,8 @@ client_ws/ WebSocket 客户端(feature = "websockets")
| Send email (2 steps) | ① `email_set`/`email_import` create draft ② `email_submission_set` send | submission associate identityId | | Send email (2 steps) | ① `email_set`/`email_import` create draft ② `email_submission_set` send | submission associate identityId |
| Delete/Archive | `email_set` (keywords `$seen`/`$flagged`), `mailbox_destroy` | JMAP has no real "delete", relying on keyword/move | | Delete/Archive | `email_set` (keywords `$seen`/`$flagged`), `mailbox_destroy` | JMAP has no real "delete", relying on keyword/move |
| Search | `email_query` (`Filter::text`) + `SearchSnippet/get` highlighting | `Filter` is a serde single-label enumeration, **no comparator syntax**; `SearchSnippet/get` only returns `emailId`/`subject`/`preview`, **no `bodyProperties`/`parts`**, body-level highlighting in locked version `0.4.2` can't do it (downgraded to a pure list of IDs) | | Search | `email_query` (`Filter::text`) + `SearchSnippet/get` highlighting | `Filter` is a serde single-label enumeration, **no comparator syntax**; `SearchSnippet/get` only returns `emailId`/`subject`/`preview`, **no `bodyProperties`/`parts`**, body-level highlighting in locked version `0.4.2` can't do it (downgraded to a pure list of IDs) |
| Real-time notification (Push + reconciliation) | Push HTTPS callback → `StateChange`; external Cron calls `/reconcile` and uses `Email/changes` to make up the difference | Requires public HTTPS entrance (deployment.md `C-HTTPS-INBOUND`/`FLOW-NEW-MAIL`); Push is not the only reliable source | | Real-time notification (Push + reconciliation) | Push HTTPS callback → `StateChange`; external Cron calls `/reconcile` and uses `Email/changes` to make up the difference | Requires public HTTPS entrance (deployment.md ``C-HTTPS-INBOUND``/`FLOW-NEW-MAIL`); Push is not the only reliable source |
| SSE / WebSocket (non-target) | `event_source` / `client_ws` | This deployment **does not use** (deployment.md `NG-POLLING-SSE`/`NG-LONG-POLLING`/`C-NO-LONG-CONN`); only crate capabilities are listed for investigation | | SSE / WebSocket (non-target) | `event_source` / `client_ws` | This deployment **does not use** (deployment.md ``NG-POLLING-SSE``/`NG-LONG-POLLING`/`C-NO-LONG-CONN`); only crate capabilities are listed for investigation |
### 2.4 Authentication mechanism (`stalwartlabs/jmap-client/src/client.rs`) ### 2.4 Authentication mechanism (`stalwartlabs/jmap-client/src/client.rs`)
- `Credentials::Basic { username, secret }` — username/password, natively supported by Stalwart. - `Credentials::Basic { username, secret }` — username/password, natively supported by Stalwart.
@ -122,9 +122,9 @@ WebSocket(...) ws 错误(feature 开启时)
``` ```
- `MethodErrorType` fine-grained: `ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → Can directly drive Bot’s retry/current limiting/state reset strategy. - `MethodErrorType` fine-grained: `ServerUnavailable`/`ServerFail`/`RateLimit`/`InvalidArguments`/`Forbidden`/`StateMismatch`/`TooManyChanges`… → Can directly drive Bot’s retry/current limiting/state reset strategy.
### 2.6 Real-time channel decision-making (determined, see deployment.md) ### 2.6 Real-time channel decision-making (determined, see docs/charter.md §8)
> Deployment target = **Generic HTTPS-only Docker, no long connections** (deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`). > Deployment target = **Generic HTTPS-only Docker, no long connections** (deployment.md ``C-NO-LONG-CONN``/`C-HTTPS-INBOUND`).
> Current channel = JMAP Push HTTPS callback (first explicitly registered by the administrator via `POST /api/push/register`, see §7.3) + external Cron `/reconcile` reconciliation. Push subscriptions are not created automatically, and Push cannot be regarded as the only reliable source. > Current channel = JMAP Push HTTPS callback (first explicitly registered by the administrator via `POST /api/push/register`, see §7.3) + external Cron `/reconcile` reconciliation. Push subscriptions are not created automatically, and Push cannot be regarded as the only reliable source.
> EventSource/SSE and WebSocket are both marked as **non-target** (`NG-POLLING-SSE`). Only the following table is retained for reference for crate capability research. > EventSource/SSE and WebSocket are both marked as **non-target** (`NG-POLLING-SSE`). Only the following table is retained for reference for crate capability research.
@ -134,7 +134,7 @@ WebSocket(...) ws 错误(feature 开启时)
| EventSource/SSE | ❌ non-target | long connection, conflicts with `C-NO-LONG-CONN` (`NG-POLLING-SSE`) | | EventSource/SSE | ❌ non-target | long connection, conflicts with `C-NO-LONG-CONN` (`NG-POLLING-SSE`) |
| WebSocket (RFC 8887) | ❌ Non-target | Long connection, conflicts with `C-NO-LONG-CONN`; and needs server support | | WebSocket (RFC 8887) | ❌ Non-target | Long connection, conflicts with `C-NO-LONG-CONN`; and needs server support |
Reconciliation: External HTTPS Cron periodically adjusts `/reconcile` (deployment.md `FLOW-RECONCILE`), uses `Email/changes` + Redis `sinceState` to make up for the difference, and also serves as cursor reconstruction after Redis is lost. Reconciliation: External HTTPS Cron periodically adjusts `/reconcile` (deployment.md ``FLOW-RECONCILE``), uses `Email/changes` + Redis `sinceState` to make up for the difference, and also serves as cursor reconstruction after Redis is lost.
--- ---
@ -144,7 +144,7 @@ Reconciliation: External HTTPS Cron periodically adjusts `/reconcile` (deploymen
- **Confirm adoption** (`C-AUTH-APP-BASIC`): `Credentials::Basic` (Stalwart account email + **App Password**). App Password can be revoked/expired independently without using a master password. Configuration injection, no code required. - **Confirm adoption** (`C-AUTH-APP-BASIC`): `Credentials::Basic` (Stalwart account email + **App Password**). App Password can be revoked/expired independently without using a master password. Configuration injection, no code required.
- **Not used**: OAuth/OIDC `Bearer` (increases complexity when there is no OIDC requirement, do not select); master password Basic. - **Not used**: OAuth/OIDC `Bearer` (increases complexity when there is no OIDC requirement, do not select); master password Basic.
- **Security**: The password is only saved in the configuration or key manager; wrapped with `secrecy::SecretString` during runtime, the log will never print plain text (see §7.2). - **Security**: The password is only saved in the configuration or key manager; wrapped with `secrecy::SecretString` during runtime, the log will never print plain text (see §7.2).
- For runtime injection methods, see deployment.md (`C-NO-SECRET-IN-IMAGE`). - For runtime injection methods, see README.md §3.0 (`C-NO-SECRET-IN-IMAGE`).
- **Single Account** (`REQ-SINGLE-ACCOUNT`): This instance only connects to one Stalwart account; multiple accounts = deploy multiple bot instances. - **Single Account** (`REQ-SINGLE-ACCOUNT`): This instance only connects to one Stalwart account; multiple accounts = deploy multiple bot instances.
### 3.2 Mailbox operation adaptation layer (JMAP ↔ Bot semantics) ### 3.2 Mailbox operation adaptation layer (JMAP ↔ Bot semantics)
@ -207,7 +207,7 @@ Candidate comparison (`teloxide` / `grammers` / old `telegram-bot`), 6 reasons w
│ worker: Email/changes → 通知 → 发往 TG → 推进游标│ │ worker: Email/changes → 通知 → 发往 TG → 推进游标│
└───────────────────────────────────────────────────┘ └───────────────────────────────────────────────────┘
``` ```
> Deployment form = **General HTTPS-only Docker, no long connection** (deployment.md `C-NO-LONG-CONN`/`C-HTTPS-INBOUND`): > Deployment form = **General HTTPS-only Docker, no long connection** (deployment.md ``C-NO-LONG-CONN``/`C-HTTPS-INBOUND`):
> Telegram is Webhook only; JMAP real-time is Push callback only; slow tasks are asynchronously stepped into Redis Streams; reconciliation is triggered by **External HTTPS Cron**. > Telegram is Webhook only; JMAP real-time is Push callback only; slow tasks are asynchronously stepped into Redis Streams; reconciliation is triggered by **External HTTPS Cron**.
> EventSource/SSE and long polling are non-targets (`NG-POLLING-SSE`/`NG-LONG-POLLING`). > EventSource/SSE and long polling are non-targets (`NG-POLLING-SSE`/`NG-LONG-POLLING`).
> Channel decoupling (see §5.2): The domain layer interacts with the channel layer through domain Command/Notification, and no specific channel SDK type appears in the domain layer; DingTalk/Feishu only retains extension bits and does not implement them in advance. > Channel decoupling (see §5.2): The domain layer interacts with the channel layer through domain Command/Notification, and no specific channel SDK type appears in the domain layer; DingTalk/Feishu only retains extension bits and does not implement them in advance.
@ -237,8 +237,8 @@ Candidate comparison (`teloxide` / `grammers` / old `telegram-bot`), 6 reasons w
4. Start **Redis Streams worker** (background task) to consume Push events → `Email/changes` → Notification → Send to TG → Push sinceState → XACK. 4. Start **Redis Streams worker** (background task) to consume Push events → `Email/changes` → Notification → Send to TG → Push sinceState → XACK.
5. **Does not hold any long connections and does not build self-timers** (`C-NO-LONG-CONN`); reconciliation is triggered by **external HTTPS Cron** `/reconcile` (`FLOW-RECONCILE`). 5. **Does not hold any long connections and does not build self-timers** (`C-NO-LONG-CONN`); reconciliation is triggered by **external HTTPS Cron** `/reconcile` (`FLOW-RECONCILE`).
- Notification sending and command processing share `Arc<JmapService>`, and internal `tokio::sync::RwLock` protects the variable cache; cross-request status always falls to external Redis (`C-REDIS-ONLY-STATE`). - Notification sending and command processing share `Arc<JmapService>`, and internal `tokio::sync::RwLock` protects the variable cache; cross-request status always falls to external Redis (`C-REDIS-ONLY-STATE`).
- **Multiple instances and load balancing (deployment form, `ARCH-LB-WORKER`)**: Since the state is all external Redis (`C-REDIS-ONLY-STATE`) and the delivery is idempotent (`MOD-DEDUP`), **the same image can be instantiated across multiple serverless platforms**, free Cloudflare Worker is used as the only entrance and failover (`C-LB-SINGLE-REG-URL`); `/reconcile` Use Redis to lock a single instance (`SAF-RECONCILE-LOCK`), and Streams to automatically amortize using the same consumer group (`MOD-STREAMS-GROUP`). See deployment.md §10 for details. **Field/channel logic does not need to be changed. ** - **Multiple instances and load balancing (deployment form, `ARCH-LB-WORKER`)**: Since the state is all external Redis (`C-REDIS-ONLY-STATE`) and the delivery is idempotent (`MOD-DEDUP`), **the same image can be instantiated across multiple serverless platforms**, free Cloudflare Worker is used as the only entrance and failover (`C-LB-SINGLE-REG-URL`); `/reconcile` Use Redis to lock a single instance (`SAF-RECONCILE-LOCK`), and Streams to automatically amortize using the same consumer group (`MOD-STREAMS-GROUP`). See README.md §6.1 for details. **Field/channel logic does not need to be changed. **
- **Production redline (`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`, see deployment.md §0 / §8.2)**: - **Production redline (`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`, see docs/charter.md §3 / docs/reference.md §9.1)**:
- **Production uses no database** (`C-NO-DB`): No SQLite/Postgres/MySQL/embedded database; Redis is the only production state store. - **Production uses no database** (`C-NO-DB`): No SQLite/Postgres/MySQL/embedded database; Redis is the only production state store.
- **Disable local file/directory writing** (`C-NO-LOCAL-WRITE`): no log files, no data files, no temporary cache, no local volumes mounted. - **Disable local file/directory writing** (`C-NO-LOCAL-WRITE`): no log files, no data files, no temporary cache, no local volumes mounted.
- **Log only writes to stdout/stderr** (`C-LOG-STDOUT-ONLY`): The container/platform is responsible for collecting and placing disk; disable the file log backend. - **Log only writes to stdout/stderr** (`C-LOG-STDOUT-ONLY`): The container/platform is responsible for collecting and placing disk; disable the file log backend.
@ -306,7 +306,7 @@ message-weave/
├── Cargo.toml # jmap-client 0.4.2 / redis 0.27 / reqwest 0.13;不含 teloxide ├── Cargo.toml # jmap-client 0.4.2 / redis 0.27 / reqwest 0.13;不含 teloxide
├── .env.example # 仅 2 个必填项的占位样例(REDIS_URL / CONFIG_ENCRYPTION_KEY),不含业务配置 ├── .env.example # 仅 2 个必填项的占位样例(REDIS_URL / CONFIG_ENCRYPTION_KEY),不含业务配置
├── .gitignore ├── .gitignore
├── Dockerfile # 仅本地开发用;生产不执行(C-DEBIAN-SLIM,边界见 deployment.md §3) ├── Dockerfile # 仅本地开发用;生产不执行(C-DEBIAN-SLIM,边界见 README.md §3.4)
├── hoststack.yaml # 生产部署真源(runtime/build/start/healthCheck),非负载均衡器清单,见 §5.1 ├── hoststack.yaml # 生产部署真源(runtime/build/start/healthCheck),非负载均衡器清单,见 §5.1
├── src/ ├── src/
│ ├── main.rs # tokio main:读 PORT/REDIS_URL/CONFIG_ENCRYPTION_KEY 后启动 webhook HTTP 入口(单入口,无 CLI 子命令) │ ├── main.rs # tokio main:读 PORT/REDIS_URL/CONFIG_ENCRYPTION_KEY 后启动 webhook HTTP 入口(单入口,无 CLI 子命令)
@ -348,7 +348,7 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
| `web` | axum | `/config` static page + `include_str!` embedding + CSP | Front-end covered by `web/config.test.mjs` | Implemented | | `web` | axum | `/config` static page + `include_str!` embedding + CSP | Front-end covered by `web/config.test.mjs` | Implemented |
| `debug` | reqwest 0.13; redis 0.27 | Remote diagnostic surface `/debug/*` (`src/debug.rs`): 6 read-only probes `/debug/ping`, `/debug/config`, `/debug/redis`, `/debug/jmap`, `/debug/telegram`, `/debug/worker` + `POST /debug/notify` (sends a test message through the production outbound path, no independent implementation) | Only `DEBUG_ENABLED` (or `--debug`) + `DEBUG_TOKEN` mounts the route when both factors are present; the module itself is compiled unconditionally | Implemented, does not enter the Worker whitelist (`SAF-DEBUG-ORIGIN-ONLY`) | | `debug` | reqwest 0.13; redis 0.27 | Remote diagnostic surface `/debug/*` (`src/debug.rs`): 6 read-only probes `/debug/ping`, `/debug/config`, `/debug/redis`, `/debug/jmap`, `/debug/telegram`, `/debug/worker` + `POST /debug/notify` (sends a test message through the production outbound path, no independent implementation) | Only `DEBUG_ENABLED` (or `--debug`) + `DEBUG_TOKEN` mounts the route when both factors are present; the module itself is compiled unconditionally | Implemented, does not enter the Worker whitelist (`SAF-DEBUG-ORIGIN-ONLY`) |
> Note: The "Current Status" column is **module level** (the module has been implemented), which does not mean that the behavior is complete. Behavioral gaps are not included in this list: `/search`, Telegram 429 backoff, and multi-instance re-delivery windows have all been implemented. `SAF-DEBUG-ALLOWLIST` (`POST /debug/notify` does not intercept any `chat_id` when the list is empty) has never been counted as a behavioral gap and was moved from `docs/opengaps.md` on 2026-09-29: its exposure in production deployments is provided by `SAF-DEBUG-GATE` two-factor mount with Worker routing safelist Hold on, **the two doors are sufficient on their own**, and there is no need for a third layer of lists, so there is no gap. This behavior and residual risks (direct connection to the backend origin can bypass both layers) are described in `docs/deployment.md` §2.1 and `docs/design.md` §7.6. The `/search` path of the `worker` module has been implemented with `bfe0fd8`. > Note: The "Current Status" column is **module level** (the module has been implemented), which does not mean that the behavior is complete. Behavioral gaps are not included in this list: `/search`, Telegram 429 backoff, and multi-instance re-delivery windows have all been implemented. `SAF-DEBUG-ALLOWLIST` (`POST /debug/notify` does not intercept any `chat_id` when the list is empty) has never been counted as a behavioral gap and was moved from `docs/opengaps.md` on 2026-09-29: its exposure in production deployments is provided by `SAF-DEBUG-GATE` two-factor mount with Worker routing safelist Hold on, **the two doors are sufficient on their own**, and there is no need for a third layer of lists, so there is no gap. This behavior and residual risks (direct connection to the backend origin can bypass both layers) are described in `docs/reference.md` §3 and `docs/design.md` §7.6. The `/search` path of the `worker` module has been implemented with `bfe0fd8`.
--- ---
@ -403,7 +403,7 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
- Only 2 process-level variables are read during startup (`REDIS_URL`, `CONFIG_ENCRYPTION_KEY`, directly `std::env::var` from `src/main.rs`; if any one is missing, it will be downgraded to read-only setup mode); the business configuration is deserialized from Redis through the `config` layer (**None** `${VAR}` interpolation/figment/config files, `ARCH-CONFIG-ENV`). - Only 2 process-level variables are read during startup (`REDIS_URL`, `CONFIG_ENCRYPTION_KEY`, directly `std::env::var` from `src/main.rs`; if any one is missing, it will be downgraded to read-only setup mode); the business configuration is deserialized from Redis through the `config` layer (**None** `${VAR}` interpolation/figment/config files, `ARCH-CONFIG-ENV`).
- Use `secrecy::SecretString` to wrap it during runtime, and `Debug` to implement `***`. - Use `secrecy::SecretString` to wrap it during runtime, and `Debug` to implement `***`.
- Log filtering: `tracing` field layer shielding `Authorization`/`password`/`token`. - Log filtering: `tracing` field layer shielding `Authorization`/`password`/`token`.
- For the runtime secret injection method (env / `*_FILE` / orchestrator secret), see deployment.md (`C-NO-SECRET-IN-IMAGE`). - For the runtime secret injection method (env / `*_FILE` / orchestrator secret), see README.md §3.0 (`C-NO-SECRET-IN-IMAGE`).
### 7.3 Access control ### 7.3 Access control
- **Entry authentication (hard constraints `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**: The three write paths must be authenticated first, **fail-closed**—— - **Entry authentication (hard constraints `SAF-AUTH-RECONCILE`/`SAF-AUTH-TG-WEBHOOK`/`SAF-AUTH-JMAP-PUSH`)**: The three write paths must be authenticated first, **fail-closed**——
@ -413,7 +413,7 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
The comparison uses the **constant time** algorithm (`subtle`, anti-timing side channel); verification failure will always be `401` and **will not produce any side effects/status changes** before the authentication is passed. After the business configuration is completed, the credentials for Webhook, Push, Reconcile and Push registration interfaces must be valid; when the startup boot variable is missing, the configuration boot mode will be entered and authentication will not be bypassed (§7.1). The comparison uses the **constant time** algorithm (`subtle`, anti-timing side channel); verification failure will always be `401` and **will not produce any side effects/status changes** before the authentication is passed. After the business configuration is completed, the credentials for Webhook, Push, Reconcile and Push registration interfaces must be valid; when the startup boot variable is missing, the configuration boot mode will be entered and authentication will not be bypassed (§7.1).
- **Health probe (`SAF-PROBE-PUBLIC`)**: `/healthz` (`ARCH-HEALTHZ`) and `/ready` are **public probes** - no authentication, only returns health status, **does not contain any sensitive information** (does not echo configuration/key/internal error details). - **Health probe (`SAF-PROBE-PUBLIC`)**: `/healthz` (`ARCH-HEALTHZ`) and `/ready` are **public probes** - no authentication, only returns health status, **does not contain any sensitive information** (does not echo configuration/key/internal error details).
- `/healthz` = liveness (process survival), long-term stable semantics. - `/healthz` = liveness (process survival), long-term stable semantics.
- `/ready` does end-to-end probing: configuration integrity + Redis reachability + outbound read-only probing (`GET {jmap_origin}/.well-known/jmap`, Basic authentication with configuration; `GET https://api.telegram.org/bot<token>/getMe`; each `PROBE_TIMEOUT` = 3000ms, **Parallel** (`tokio::join!`), worst-case scenario 3s); all four pass `200` and readiness report (`{"status":"ready","configured":...,"jmap":...,"telegram":...}`, where `jmap`/`telegram` is the real probe result), any one fails `503` (standard error envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`). The probe is read-only, read-only configuration status, and will not trigger business side effects such as email synchronization, nor will it echo token or third-party response content; `refresh_business_config` only reads Redis, and has no state to write. Therefore `/ready` requires the outbound egress to the JMAP host and `api.telegram.org:443` to be reachable (if the egress requires a proxy, `/ready` is not available, see deployment.md). - `/ready` does end-to-end probing: configuration integrity + Redis reachability + outbound read-only probing (`GET {jmap_origin}/.well-known/jmap`, Basic authentication with configuration; `GET https://api.telegram.org/bot<token>/getMe`; each `PROBE_TIMEOUT` = 3000ms, **Parallel** (`tokio::join!`), worst-case scenario 3s); all four pass `200` and readiness report (`{"status":"ready","configured":...,"jmap":...,"telegram":...}`, where `jmap`/`telegram` is the real probe result), any one fails `503` (standard error envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`). The probe is read-only, read-only configuration status, and will not trigger business side effects such as email synchronization, nor will it echo token or third-party response content; `refresh_business_config` only reads Redis, and has no state to write. Therefore `/ready` requires the outbound egress to the JMAP host and `api.telegram.org:443` to be reachable (if the egress requires a proxy, `/ready` is not available, see README.md §3.0).
- **chat whitelist (hard constraint `SAF-CHAT-ALLOWLIST`)**: `CHAT_ALLOWLIST` is a **required** configuration; any inbound event (action triggered by TG command/callback) must verify `chat.id ∈ CHAT_ALLOWLIST` before **making any JMAP call, AI call or status change**. If it is not in the whitelist, **directly reject and terminate** (prevent token can be called by anyone after being leaked). Phase 0 has completed the `CHAT_ALLOWLIST` parsing skeleton; the forced rejection logic has been implemented with Telegram channel access (`telegram_webhook` of `src/notify.rs` verifies the whitelist before any JMAP/AI/status operation, and terminates if rejected). - **chat whitelist (hard constraint `SAF-CHAT-ALLOWLIST`)**: `CHAT_ALLOWLIST` is a **required** configuration; any inbound event (action triggered by TG command/callback) must verify `chat.id ∈ CHAT_ALLOWLIST` before **making any JMAP call, AI call or status change**. If it is not in the whitelist, **directly reject and terminate** (prevent token can be called by anyone after being leaked). Phase 0 has completed the `CHAT_ALLOWLIST` parsing skeleton; the forced rejection logic has been implemented with Telegram channel access (`telegram_webhook` of `src/notify.rs` verifies the whitelist before any JMAP/AI/status operation, and terminates if rejected).
- **Command Minimization**: Only necessary commands are exposed; writing operations such as sending emails must be confirmed twice (sending emails is not currently implemented, see §10.3). - **Command Minimization**: Only necessary commands are exposed; writing operations such as sending emails must be confirmed twice (sending emails is not currently implemented, see §10.3).
- **Rate**: No local token bucket is built on the outbound side; Telegram's outbound sending is retried according to the Redis operating parameter `max_retries` (see `docs/reference.md` §6.1 for default values and hard upper limits); currently only Redis current limiting is done for Push verification code writing (`ratelimit:push-verify:*`). 429 under the 30 msg/s limit of the Telegram server **Automatic backoff in `parameters.retry_after` seconds** (`channel.rs`, `f4cae00`): `retry_after_ms` is truncated to the 60s budget limit after parsing, and the exponential backoff is used when this field is missing or non-numeric `backoff_delay_ms` (starting from 250ms, capped 4s), the overall retry budget is 60s. There is still no local token bucket current limit - if the budget is exceeded, a failure will be returned directly and the upstream will try again. - **Rate**: No local token bucket is built on the outbound side; Telegram's outbound sending is retried according to the Redis operating parameter `max_retries` (see `docs/reference.md` §6.1 for default values and hard upper limits); currently only Redis current limiting is done for Push verification code writing (`ratelimit:push-verify:*`). 429 under the 30 msg/s limit of the Telegram server **Automatic backoff in `parameters.retry_after` seconds** (`channel.rs`, `f4cae00`): `retry_after_ms` is truncated to the 60s budget limit after parsing, and the exponential backoff is used when this field is missing or non-numeric `backoff_delay_ms` (starting from 250ms, capped 4s), the overall retry budget is 60s. There is still no local token bucket current limit - if the budget is exceeded, a failure will be returned directly and the upstream will try again.
@ -444,7 +444,7 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
- Certificate verification is **mandatory by default** and can be turned off only for testing. - Certificate verification is **mandatory by default** and can be turned off only for testing.
- For operation and maintenance details such as the system CA trust store in the image, internal CA injection, inbound webhook TLS termination, etc., see deployment.md. - For operation and maintenance details such as the system CA trust store in the image, internal CA injection, inbound webhook TLS termination, etc., see deployment.md.
### 7.5 Production red line: no database / no local writing / standard output log (deployment.md §0/§8.2) ### 7.5 Production red line: no database / no local writing / standard output log (README.md §3.0 / docs/reference.md §9.1)
This section summarizes the absolute red lines across code and deployment that you must not introduce any "seemingly convenient" local state into your code: This section summarizes the absolute red lines across code and deployment that you must not introduce any "seemingly convenient" local state into your code:
@ -456,11 +456,11 @@ This section summarizes the absolute red lines across code and deployment that y
| `SAF-LOG-PURITY` | Log and Redis writing content is limited to structured events, counts, timestamps, desensitized summaries | Log printing JMAP text, AI prompt/completion, key original text, attachment content | | `SAF-LOG-PURITY` | Log and Redis writing content is limited to structured events, counts, timestamps, desensitized summaries | Log printing JMAP text, AI prompt/completion, key original text, attachment content |
| `C-NO-STATEFUL-RECOVERY` | Production recovery does not rely on in-process state | Use `static Mutex<HashSet>` to store dedup and use memory LRU to store sinceState as the only recovery source | | `C-NO-STATEFUL-RECOVERY` | Production recovery does not rely on in-process state | Use `static Mutex<HashSet>` to store dedup and use memory LRU to store sinceState as the only recovery source |
**Verification**: No local path constants / `std::fs` non-test calls / file log backends appear in the static inspection code; during runtime, the container `/proc/mounts` and `docker inspect` are used to confirm that there is no local volume mounting; see deployment.md §8.2 for the deployment checklist. **Verification**: No local path constants / `std::fs` non-test calls / file log backends appear in the static inspection code; during runtime, the container `/proc/mounts` and `docker inspect` are used to confirm that there is no local volume mounting; see docs/reference.md §9.1 for the deployment checklist.
### 7.6 Why is the remote joint debugging interface absolutely closed by default? ### 7.6 Why is the remote joint debugging interface absolutely closed by default?
The only reason for this surface to exist is to shorten the production troubleshooting path: when you cannot log in to the container and can only rely on external requests to observe the system, someone needs to be able to detect the JMAP/Telegram connectivity and current business configuration without releasing the version. The price is that its entry softness must be higher than the three write paths - in addition to read-only detection, it also retains a **real outbound send**, and reuses the business whitelist instead of an independent whitelist. Therefore, the design choice is "closed by default" instead of "enabled by default and blocked by the load balancer": the process will only mount this set of routes (`SAF-DEBUG-GATE`) when "`DEBUG_ENABLED` is true or the command line has `--debug`"** and **a non-empty `DEBUG_TOKEN` is set; when the route is missing, the route does not exist at all in the router, and the request falls to axum general `404`, not "Exists but 401" - the latter would leak the route's existence. There is no third state: there is no "open without configuration" fallback, and there is no configuration item to set it to be enabled by default. `DEBUG_ENABLED` uses environment variables instead of startup commands so that the switch can be changed once from the platform console without releasing the version - the startup command remains static. Add another layer of location constraints: it is not in the load balancer's safe routing whitelist. Even if the backend is opened incorrectly, the platform entrance will be rejected by fail-closed. The only reachable path is to directly connect to the backend origin. These four things—two-factor mounting, 404 instead of 401, no default enabled fallback, and unreachable load balancer—together form the architectural decision of “exposure is zero by default.” See deployment.md §2.1 for the enablement method, per-endpoint status code, and deployment confirmation checklist. The only reason for this surface to exist is to shorten the production troubleshooting path: when you cannot log in to the container and can only rely on external requests to observe the system, someone needs to be able to detect the JMAP/Telegram connectivity and current business configuration without releasing the version. The price is that its entry softness must be higher than the three write paths - in addition to read-only detection, it also retains a **real outbound send**, and reuses the business whitelist instead of an independent whitelist. Therefore, the design choice is "closed by default" instead of "enabled by default and blocked by the load balancer": the process will only mount this set of routes (`SAF-DEBUG-GATE`) when "`DEBUG_ENABLED` is true or the command line has `--debug`"** and **a non-empty `DEBUG_TOKEN` is set; when the route is missing, the route does not exist at all in the router, and the request falls to axum general `404`, not "Exists but 401" - the latter would leak the route's existence. There is no third state: there is no "open without configuration" fallback, and there is no configuration item to set it to be enabled by default. `DEBUG_ENABLED` uses environment variables instead of startup commands so that the switch can be changed once from the platform console without releasing the version - the startup command remains static. Add another layer of location constraints: it is not in the load balancer's safe routing whitelist. Even if the backend is opened incorrectly, the platform entrance will be rejected by fail-closed. The only reachable path is to directly connect to the backend origin. These four things—two-factor mounting, 404 instead of 401, no default enabled fallback, and unreachable load balancer—together form the architectural decision of “exposure is zero by default.” See docs/reference.md §3 for the enablement method, per-endpoint status code, and deployment confirmation checklist.
--- ---
@ -503,7 +503,7 @@ The design target form once included six additional variants: `Jmap(#[from] jmap
- `tracing` (direct dependency) + `tracing-subscriber` (fmt + EnvFilter). **Currently only recording startup events**: `src/main.rs` 5 `info!`/`warn!` in total (missing startup variables, downgrade to setup mode, startup banner, debug endpoint enabled, JMAP service unavailable, downgrade × 2). Request-level events (Push callbacks, Reconcile pulls, channel pushes, LLM elapsed times) **not implemented yet** - there are no tracing calls in `src/` except `main.rs`, and no named spans (no `#[instrument]` / `span!`). - `tracing` (direct dependency) + `tracing-subscriber` (fmt + EnvFilter). **Currently only recording startup events**: `src/main.rs` 5 `info!`/`warn!` in total (missing startup variables, downgrade to setup mode, startup banner, debug endpoint enabled, JMAP service unavailable, downgrade × 2). Request-level events (Push callbacks, Reconcile pulls, channel pushes, LLM elapsed times) **not implemented yet** - there are no tracing calls in `src/` except `main.rs`, and no named spans (no `#[instrument]` / `span!`).
- Metrics (optional `metrics` crate): Number of Push callback arrivals, deduplication hit rate, Redis Streams backlog depth (pending), number of DLQ items, number of reconciliation items, JMAP request delay, push failure rate. **Currently not connected to any metrics backend** (`metrics` is not in `Cargo.toml`). - Metrics (optional `metrics` crate): Number of Push callback arrivals, deduplication hit rate, Redis Streams backlog depth (pending), number of DLQ items, number of reconciliation items, JMAP request delay, push failure rate. **Currently not connected to any metrics backend** (`metrics` is not in `Cargo.toml`).
- Graceful exit: **Currently not implemented** - There is no signal processing in `src/` (no `tokio::signal` / `ctrl_c`), and there is no flush logic for Streams pending entries; the process is terminated when the container stops. Redis side `C-NO-STATEFUL-RECOVERY` ensures reconstruction from Redis after restart and does not rely on in-process state. - Graceful exit: **Currently not implemented** - There is no signal processing in `src/` (no `tokio::signal` / `ctrl_c`), and there is no flush logic for Streams pending entries; the process is terminated when the container stops. Redis side `C-NO-STATEFUL-RECOVERY` ensures reconstruction from Redis after restart and does not rely on in-process state.
- **Reliability goals and strategies** (Streams ACK/retry, idempotent deduplication, Push retry, reconciliation recovery, indicators/alarms, **≥99.9% notification availability and boundaries**) see deployment.md §6.4/§6.5 (`NFR-NOTIFY-SLA`). - **Reliability goals and strategies** (Streams ACK/retry, idempotent deduplication, Push retry, reconciliation recovery, indicators/alarms, **≥99.9% notification availability and boundaries**) see README.md §6.3 (`NFR-NOTIFY-SLA`).
--- ---
@ -547,7 +547,7 @@ The design target form once included six additional variants: `Jmap(#[from] jmap
## 10. Phased implementation plan ## 10. Phased implementation plan
> Prerequisite: `rustup` first installs the tool chain (stable). CI and release flows are in deployment.md. > Prerequisite: `rustup` first installs the tool chain (stable). CI and release flows are in README.md §3.
### 10.0 Phase 0: Scaffolding and HTTPS entry skeleton (completed, `GATE-P0` passed) ### 10.0 Phase 0: Scaffolding and HTTPS entry skeleton (completed, `GATE-P0` passed)
@ -669,7 +669,7 @@ Single authority table, this section only retains four "whys" that affect design
**Audit Opinion → Closing (2026-09-26)** **Audit Opinion → Closing (2026-09-26)**
- [Repair-2] When the enqueue failed, dedup released best-effort, which may have caused a 24h silent loss event → Fixed to `claim_dedup_and_enqueue` (Lua atoms: `SET NX EX` must succeed before `XADD`), there is no intermediate failure window between claim and enqueue. - [Repair-2] When the enqueue failed, dedup released best-effort, which may have caused a 24h silent loss event → Fixed to `claim_dedup_and_enqueue` (Lua atoms: `SET NX EX` must succeed before `XADD`), there is no intermediate failure window between claim and enqueue.
- [Should be revised-1] `Email/changes` relies on `newState` for continued transmission, `jmap-client 0.4.2` does not have `upToId` → has been changed to "Same as `sinceState`, double `maxChanges` one by one to expand the window (upper limit 4096), only advance `new_state` when the window cannot be expanded" to avoid missing batches when advancing by page; `newState` The semantics have been confirmed to be load-bearing (`docs/opengaps.md` §2), and the callback verification round-trip and backlog emptying in the real Stalwart environment follow the path of baseline → increment `/changes` → `newState` (`docs/deployment.md` §4.1, §6.3.1). - [Should be revised-1] `Email/changes` relies on `newState` for continued transmission, `jmap-client 0.4.2` does not have `upToId` → has been changed to "Same as `sinceState`, double `maxChanges` one by one to expand the window (upper limit 4096), only advance `new_state` when the window cannot be expanded" to avoid missing batches when advancing by page; `newState` The semantics have been confirmed to be load-bearing (`docs/opengaps.md` §2), and the callback verification round-trip and backlog emptying in the real Stalwart environment follow the path of baseline → increment `/changes` → `newState` (`README.md` §3.7, `README.md` §3.10).
- The rest of the low-risk items have been closed: the empty value of the unknown stream is changed to `Err` (fail-closed, enter retry/DLQ); `push:disable` is cleared by `forget_push_subscription`; the lower limit of `SET NX EX` TTL is tightened to `.max(1)`; XAUTOCLAIM idle threshold is scaled according to the batch size; malformed stream entries without payload are determined by `ack_malformed` is moved out of PEL via `XACK`; CSPRNG changes the owner-token to "time + PID + counter" and no longer uses constants. - The rest of the low-risk items have been closed: the empty value of the unknown stream is changed to `Err` (fail-closed, enter retry/DLQ); `push:disable` is cleared by `forget_push_subscription`; the lower limit of `SET NX EX` TTL is tightened to `.max(1)`; XAUTOCLAIM idle threshold is scaled according to the batch size; malformed stream entries without payload are determined by `ack_malformed` is moved out of PEL via `XACK`; CSPRNG changes the owner-token to "time + PID + counter" and no longer uses constants.
- Unscheduled (Phase 5 "Search + Search Snippet") ** Closed (`bfe0fd8`)**: `/search` uses `email_query`(`Filter::text`) + `SearchSnippet/get`. For the upper limit of highlight degradation and truncation, see §2.3 and §5.7; text-level highlighting cannot be done in the locked version (see §2.3 remarks). Stage 5 Backlog cleared. - Unscheduled (Phase 5 "Search + Search Snippet") ** Closed (`bfe0fd8`)**: `/search` uses `email_query`(`Filter::text`) + `SearchSnippet/get`. For the upper limit of highlight degradation and truncation, see §2.3 and §5.7; text-level highlighting cannot be done in the locked version (see §2.3 remarks). Stage 5 Backlog cleared.
@ -680,20 +680,20 @@ Single authority table, this section only retains four "whys" that affect design
## 11. Archive of historical issues and decisions (product/architecture category, all have conclusions) ## 11. Archive of historical issues and decisions (product/architecture category, all have conclusions)
> ⚠️ Deployment/platform decisions have been **all confirmed** (single account / App Password+Basic / Redis hosting + AOF / platform HTTPS URL / external Cron reconciliation / `Q-DEP-A` platform URL and certificate configurator / `Q-DEP-B` scheduler selection), which have been filed in the Confirmed Decisions section of `docs/deployment.md` and will not be repeated in this article. > ⚠️ Deployment/platform decisions have been **all confirmed** (single account / App Password+Basic / Redis hosting + AOF / platform HTTPS URL / external Cron reconciliation / `Q-DEP-A` platform URL and certificate configurator / `Q-DEP-B` scheduler selection), which have been filed in the Confirmed Decisions section of `docs/charter.md` §8 and will not be repeated in this article.
### 11.1 Authentication method (confirmed) ### 11.1 Authentication method (confirmed)
- **Confirmed**: **App Password + Basic** (`C-AUTH-APP-BASIC`). No master password, no OAuth2 Bearer (no OIDC required). - **Confirmed**: **App Password + Basic** (`C-AUTH-APP-BASIC`). No master password, no OAuth2 Bearer (no OIDC required).
- Affects: `Credentials::Basic` construct; no need for OAuth client/token auto-refresh module. - Affects: `Credentials::Basic` construct; no need for OAuth client/token auto-refresh module.
### 11.2 Real-time channel (decided, see deployment.md) ### 11.2 Real-time channel (decided, see docs/charter.md §8)
- Channel = **JMAP Push HTTPS callback + external Cron reconciliation and draining** (`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`); EventSource/SSE/WebSocket are all **non-target** (`NG-POLLING-SSE`). Push registration is triggered explicitly through protected `POST /api/push/register`; external Cron must call `/reconcile` (incremental enqueue) and `/worker` (drain concurrent notification) at the same time - **Only adjusting reconciliation will make the notification never sent** (deployment.md §6.3.1). - Channel = **JMAP Push HTTPS callback + external Cron reconciliation and draining** (`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`); EventSource/SSE/WebSocket are all **non-target** (`NG-POLLING-SSE`). Push registration is triggered explicitly through protected `POST /api/push/register`; external Cron must call `/reconcile` (incremental enqueue) and `/worker` (drain concurrent notification) at the same time - **Only adjusting reconciliation will make the notification never sent** (README.md §3.10).
- Stalwart side access has been verified in the real environment: the built-in role has the `PushSubscription` permission, and registration, verification and round-trip and real callback delivery are all passed. The match between the number of callback retries and the idempotent key TTL/reconciliation interval is an operation and maintenance parameter item, not an acceptance threshold: `docs/deployment.md` §5 provides retry suggestions, §6.3.1 provides a value for the reconciliation interval, and §7 explains that the idempotent key TTL must cover the TG rate-limit fallback upper limit. `docs/opengaps.md` currently has no outstanding items. - Stalwart side access has been verified in the real environment: the built-in role has the `PushSubscription` permission, and registration, verification and round-trip and real callback delivery are all passed. The match between the number of callback retries and the idempotent key TTL/reconciliation interval is an operation and maintenance parameter item, not an acceptance threshold: `docs/reference.md` §9.3 provides retry suggestions, README.md §3.10 provides a value for the reconciliation interval, and `docs/reference.md` §1 explains that the idempotent key TTL must cover the TG rate-limit fallback upper limit. `docs/opengaps.md` currently has no outstanding items.
### 11.3 Deployment form (confirmed, architecture related) ### 11.3 Deployment form (confirmed, architecture related)
- **Confirmed**: **Single account implementation** (`REQ-SINGLE-ACCOUNT`); multiple accounts temporarily use **multiple bot instances** (respective tokens/configurations), **no multiple accounts single instance** (so no need for chat→account routing and `JmapService` pooling). 2026-09-28 Users have made this decision, and the boundaries and future changes to be made are registered in `docs/opengaps.md` §3. - **Confirmed**: **Single account implementation** (`REQ-SINGLE-ACCOUNT`); multiple accounts temporarily use **multiple bot instances** (respective tokens/configurations), **no multiple accounts single instance** (so no need for chat→account routing and `JmapService` pooling). 2026-09-28 Users have made this decision, and the boundaries and future changes to be made are registered in `docs/opengaps.md` §3.
- Deployment shape = **webhook-only + universal HTTPS-only Docker + external Cron reconciliation** (deployment.md `C-NO-LONG-CONN`/`NG-LONG-POLLING`/`NFR-RECONCILE-INTERVAL`). - Deployment shape = **webhook-only + universal HTTPS-only Docker + external Cron reconciliation** (deployment.md ``C-NO-LONG-CONN``/`NG-LONG-POLLING`/`NFR-RECONCILE-INTERVAL`).
- **Multi-instance LB/HA (confirmed, `ARCH-LB-WORKER`)**: You can optionally deploy the same image on multiple serverless platforms, share the same Redis, and use free Cloudflare Worker as the only entrance and failover; the Worker code is located in the subdirectory [`cloudflare-worker/`](../cloudflare-worker/) (not a native Rust binary). For deployment, see `docs/deployment.md` for Worker Deployment section. The trust model is **transparent transmission** (the backend still fails-closed verification, `SAF-LB-PASSTHRU`), the backends share the same set of secrets** (`C-LB-SHARED-SECRETS`), `/reconcile` **Redis lock** single instance (`SAF-RECONCILE-LOCK`), and the Worker provides **aggregated health view** (`MOD-HEALTH-AGG`). **Redis single point of failure is not within the scope of this solution** (`NFR-HA-MULTI-INSTANCE`, solved externally by the user). Both active-active and active-standby are available. See the Multi-Instance Deployment section of `docs/deployment.md` for details. - **Multi-instance LB/HA (confirmed, `ARCH-LB-WORKER`)**: You can optionally deploy the same image on multiple serverless platforms, share the same Redis, and use free Cloudflare Worker as the only entrance and failover; the Worker code is located in the subdirectory [`cloudflare-worker/`](../cloudflare-worker/) (not a native Rust binary). For deployment, see `cloudflare-worker/README.md`. The trust model is **transparent transmission** (the backend still fails-closed verification, `SAF-LB-PASSTHRU`), the backends share the same set of secrets** (`C-LB-SHARED-SECRETS`), `/reconcile` **Redis lock** single instance (`SAF-RECONCILE-LOCK`), and the Worker provides **aggregated health view** (`MOD-HEALTH-AGG`). **Redis single point of failure is not within the scope of this solution** (`NFR-HA-MULTI-INSTANCE`, solved externally by the user). Both active-active and active-standby are available. See README.md §6.1 for details.
### 11.4 Early questions answered by code (no longer pending confirmation) ### 11.4 Early questions answered by code (no longer pending confirmation)
The following questions were listed in the form of Q6-Q30 during the design phase. The answers were given when the code was implemented, so they are no longer "items to be confirmed". Only the conclusions are recorded here: The following questions were listed in the form of Q6-Q30 during the design phase. The answers were given when the code was implemented, so they are no longer "items to be confirmed". Only the conclusions are recorded here:
@ -704,11 +704,11 @@ The following questions were listed in the form of Q6-Q30 during the design phas
- **Summary Aggregation/Timed Aggregation** (Q10): Not implemented, only `/reconcile`. - **Summary Aggregation/Timed Aggregation** (Q10): Not implemented, only `/reconcile`.
- **Multiple Sending Identities** (Q12): `Identity` is not used, single account (`REQ-SINGLE-ACCOUNT`). - **Multiple Sending Identities** (Q12): `Identity` is not used, single account (`REQ-SINGLE-ACCOUNT`).
- **unsafe** (Q13): There is no `unsafe` in `src/`, but `#![forbid(unsafe_code)]` is not added either. - **unsafe** (Q13): There is no `unsafe` in `src/`, but `#![forbid(unsafe_code)]` is not added either.
- **Monitoring** (Q15): Implemented as `/healthz` + `/ready` two HTTP probes, without introducing Prometheus/Exporter; see deployment.md for the running platform configuration. - **Monitoring** (Q15): Implemented as `/healthz` + `/ready` two HTTP probes, without introducing Prometheus/Exporter; see docs/reference.md §9.4 for the running platform configuration.
- **LLM Provider/Network License** (Q25, Q26, Q30): `LLM_BASE_URL` is specified by the deployer (only verifies https); `LLM_ENABLED` and `LLM_ALLOW_NET` **default are `false`**, both must be true at the same time to construct the client, otherwise the `llm` field is `None` (`Option<Arc<LlmClient>>`, no `noop()` implementation exists in the warehouse), no detection (see §12.2). - **LLM Provider/Network License** (Q25, Q26, Q30): `LLM_BASE_URL` is specified by the deployer (only verifies https); `LLM_ENABLED` and `LLM_ALLOW_NET` **default are `false`**, both must be true at the same time to construct the client, otherwise the `llm` field is `None` (`Option<Arc<LlmClient>>`, no `noop()` implementation exists in the warehouse), no detection (see §12.2).
- **Break Cooling/Threshold** (Q27): Not applicable - the fuse itself is not implemented (see §12.4). - **Break Cooling/Threshold** (Q27): Not applicable - the fuse itself is not implemented (see §12.4).
There are no pending decisions regarding deployment/platform decisions: `Q-DEP-A` (who configures the platform URL/domain name and certificate, is determined by the deployment environment at the time of release) and `Q-DEP-B` (which scheduler is used for external Cron, does not limit the implementation) have both been decided and are archived in the Confirmed Decisions section of `docs/deployment.md` and will not be repeated in this article. There are no pending decisions regarding deployment/platform decisions: `Q-DEP-A` (who configures the platform URL/domain name and certificate, is determined by the deployment environment at the time of release) and `Q-DEP-B` (which scheduler is used for external Cron, does not limit the implementation) have both been decided and are archived in the Confirmed Decisions section of `docs/charter.md` §8 and will not be repeated in this article.
--- ---
@ -749,7 +749,7 @@ The LLM capability only exists in the `src/ai.rs` file; **no** configuration/fal
| `max_retries` (running parameters) | int | See `docs/reference.md` §6.1 | Issued by `OutboundConfig` (`RuntimeConfigProvider`), see `docs/reference.md` §6.1 for the hard upper limit; LLM is shared with Telegram outbound | | `max_retries` (running parameters) | int | See `docs/reference.md` §6.1 | Issued by `OutboundConfig` (`RuntimeConfigProvider`), see `docs/reference.md` §6.1 for the hard upper limit; LLM is shared with Telegram outbound |
| `llm_timeout_ms` (running parameter) | int | See `docs/reference.md` §6.1 | Issued by `OutboundConfig`; unit milliseconds, for the lower limit and value range, see `docs/reference.md` §6.1 | | `llm_timeout_ms` (running parameter) | int | See `docs/reference.md` §6.1 | Issued by `OutboundConfig`; unit milliseconds, for the lower limit and value range, see `docs/reference.md` §6.1 |
**None** `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES`: The request body only has `model` / `messages` / `max_tokens` (= `max_chars * 2`), no temperature, and no timeout switch independent of `llm_timeout_ms`. The API key is only read from the configuration, without entering the source code or logging (`SAF-LOG-PURITY`). For the runtime secret injection method, see deployment.md (`C-NO-SECRET-IN-IMAGE`). **None** `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES`: The request body only has `model` / `messages` / `max_tokens` (= `max_chars * 2`), no temperature, and no timeout switch independent of `llm_timeout_ms`. The API key is only read from the configuration, without entering the source code or logging (`SAF-LOG-PURITY`). For the runtime secret injection method, see README.md §3.0 (`C-NO-SECRET-IN-IMAGE`).
### 12.3 Text acquisition strategy and long email processing (requirement 1/2/3/5) ### 12.3 Text acquisition strategy and long email processing (requirement 1/2/3/5)
- **View original text = JMAP direct access**: `Intent::Query` goes through `JmapService::read_email` with any "read text" action, **without going through LLM**; LLM is not on the viewing path. - **View original text = JMAP direct access**: `Intent::Query` goes through `JmapService::read_email` with any "read text" action, **without going through LLM**; LLM is not on the viewing path.

View File

@ -10,9 +10,9 @@
> **相关文档**(职责分离,避免重复堆砌): > **相关文档**(职责分离,避免重复堆砌):
> - [docs/charter.md](charter.zh-CN.md) — 项目章程:项目目标、技术选型、安全不变量、实现阶段、禁止事项、测试验收与稳定 ID 注册表 > - [docs/charter.md](charter.zh-CN.md) — 项目章程:项目目标、技术选型、安全不变量、实现阶段、禁止事项、测试验收与稳定 ID 注册表
> - [AGENTS.md](../AGENTS.md) — 通用、语言无关的代码编写与环境构建规范(不含项目专属内容) > - [AGENTS.md](../AGENTS.md) — 通用、语言无关的代码编写与环境构建规范(不含项目专属内容)
> - [docs/deployment.md](deployment.zh-CN.md) — 部署运维与发布:通用 HTTPS-only Docker 容器、密钥管理、Redis(状态唯一载体)、短请求 Webhook/Push/对账路由、健康检查、CI 发布与仍需确认项 > - [README.md](../README.md) — 部署运维:HTTPS-only Docker、密钥管理、Redis(状态唯一载体)、短请求 webhook/push/对账路由、健康检查、多实例运维
> >
> 本文档只保留与**产品行为、代码架构、模块接口、状态机、数据流、错误处理、测试和实施阶段**直接相关的内容。部署运维与 Docker/通用容器平台细节已移至 `deployment.md`;项目约束与安全不变量在 `docs/charter.md`;通用、语言无关的代码编写与环境构建规范在 `../AGENTS.md`。 > 本文档只保留与**产品行为、代码架构、模块接口、状态机、数据流、错误处理、测试和实施阶段**直接相关的内容。部署运维与 Docker/通用容器平台细节已移至 [README.md §6](../README.md);项目约束与安全不变量在 `docs/charter.md`;通用、语言无关的代码编写与环境构建规范在 `../AGENTS.md`。
--- ---
@ -237,8 +237,8 @@ Telegram 渠道用 `src/channel.rs` 的 `reqwest` 自研实现(`ARCH-DEPS-STAG
4. 启动 **Redis Streams worker**(后台 task)消费 Push 事件 → `Email/changes` → 通知 → 发往 TG → 推进 sinceState → XACK。 4. 启动 **Redis Streams worker**(后台 task)消费 Push 事件 → `Email/changes` → 通知 → 发往 TG → 推进 sinceState → XACK。
5. **不持有任何长连接、不自建定时器**(`C-NO-LONG-CONN`);对账由**外部 HTTPS Cron** 触发 `/reconcile`(`FLOW-RECONCILE`)。 5. **不持有任何长连接、不自建定时器**(`C-NO-LONG-CONN`);对账由**外部 HTTPS Cron** 触发 `/reconcile`(`FLOW-RECONCILE`)。
- 通知发送与命令处理共享 `Arc<JmapService>`,内部 `tokio::sync::RwLock` 保护可变缓存;跨请求状态一律落外部 Redis(`C-REDIS-ONLY-STATE`)。 - 通知发送与命令处理共享 `Arc<JmapService>`,内部 `tokio::sync::RwLock` 保护可变缓存;跨请求状态一律落外部 Redis(`C-REDIS-ONLY-STATE`)。
- **多实例与负载均衡(部署形态,`ARCH-LB-WORKER`)**:由于状态全在外部 Redis(`C-REDIS-ONLY-STATE`)且投递幂等(`MOD-DEDUP`),**同一镜像可跨多个 serverless 平台实例化**,前面用免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`);`/reconcile` 用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`),Streams 用同一消费组自动分摊(`MOD-STREAMS-GROUP`)。详见 deployment.md §10。**领域/渠道逻辑无需改动。** - **多实例与负载均衡(部署形态,`ARCH-LB-WORKER`)**:由于状态全在外部 Redis(`C-REDIS-ONLY-STATE`)且投递幂等(`MOD-DEDUP`),**同一镜像可跨多个 serverless 平台实例化**,前面用免费 Cloudflare Worker 做唯一入口与故障转移(`C-LB-SINGLE-REG-URL`);`/reconcile` 用 Redis 锁单实例执行(`SAF-RECONCILE-LOCK`),Streams 用同一消费组自动分摊(`MOD-STREAMS-GROUP`)。详见 README.md §6.1。**领域/渠道逻辑无需改动。**
- **生产红线(`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`,详见 deployment.md §0 / §8.2)**: - **生产红线(`C-NO-DB` / `C-NO-LOCAL-WRITE` / `C-LOG-STDOUT-ONLY` / `SAF-LOG-PURITY` / `C-NO-STATEFUL-RECOVERY`,详见 docs/charter.md §3 / docs/reference.md §9.1)**:
- **生产不使用任何数据库**(`C-NO-DB`):无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一生产状态存储。 - **生产不使用任何数据库**(`C-NO-DB`):无 SQLite/Postgres/MySQL/嵌入式数据库;Redis 是唯一生产状态存储。
- **禁止本地文件/目录写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。 - **禁止本地文件/目录写入**(`C-NO-LOCAL-WRITE`):无日志文件、无数据文件、无临时缓存、不挂载本地卷。
- **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):容器/平台负责采集落盘;禁用文件日志后端。 - **日志只写 stdout/stderr**(`C-LOG-STDOUT-ONLY`):容器/平台负责采集落盘;禁用文件日志后端。
@ -306,7 +306,7 @@ message-weave/
├── Cargo.toml # jmap-client 0.4.2 / redis 0.27 / reqwest 0.13;不含 teloxide ├── Cargo.toml # jmap-client 0.4.2 / redis 0.27 / reqwest 0.13;不含 teloxide
├── .env.example # 仅 2 个必填项的占位样例(REDIS_URL / CONFIG_ENCRYPTION_KEY),不含业务配置 ├── .env.example # 仅 2 个必填项的占位样例(REDIS_URL / CONFIG_ENCRYPTION_KEY),不含业务配置
├── .gitignore ├── .gitignore
├── Dockerfile # 仅本地开发用;生产不执行(C-DEBIAN-SLIM,边界见 deployment.md §3) ├── Dockerfile # 仅本地开发用;生产不执行(C-DEBIAN-SLIM,边界见 README.md §3.4)
├── hoststack.yaml # 生产部署真源(runtime/build/start/healthCheck),非负载均衡器清单,见 §5.1 ├── hoststack.yaml # 生产部署真源(runtime/build/start/healthCheck),非负载均衡器清单,见 §5.1
├── src/ ├── src/
│ ├── main.rs # tokio main:读 PORT/REDIS_URL/CONFIG_ENCRYPTION_KEY 后启动 webhook HTTP 入口(单入口,无 CLI 子命令) │ ├── main.rs # tokio main:读 PORT/REDIS_URL/CONFIG_ENCRYPTION_KEY 后启动 webhook HTTP 入口(单入口,无 CLI 子命令)
@ -456,11 +456,11 @@ message-weave/
| `SAF-LOG-PURITY` | 日志与 Redis 写入内容仅限结构化事件、计数、时间戳、脱敏摘要 | 日志打印 JMAP 正文、AI prompt/completion、密钥原文、附件内容 | | `SAF-LOG-PURITY` | 日志与 Redis 写入内容仅限结构化事件、计数、时间戳、脱敏摘要 | 日志打印 JMAP 正文、AI prompt/completion、密钥原文、附件内容 |
| `C-NO-STATEFUL-RECOVERY` | 生产恢复不依赖进程内状态 | 用 `static Mutex<HashSet>` 存 dedup、用内存 LRU 存 sinceState 作为唯一恢复源 | | `C-NO-STATEFUL-RECOVERY` | 生产恢复不依赖进程内状态 | 用 `static Mutex<HashSet>` 存 dedup、用内存 LRU 存 sinceState 作为唯一恢复源 |
**验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 deployment.md §8.2。 **验证**:静态检查代码不出现本地路径常量 / `std::fs` 非测试调用 / 文件日志后端;运行期通过容器 `/proc/mounts` 与 `docker inspect` 确认无本地卷挂载;部署检查清单见 docs/reference.md §9.1。
### 7.6 为什么远程联调接口必须默认完全关闭? ### 7.6 为什么远程联调接口必须默认完全关闭?
该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠负载均衡器挡」:进程满足「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且**设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。`DEBUG_ENABLED` 走环境变量而非启动命令,是为了让开关能在不发版的前提下从平台控制台改一次完成——启动命令保持静态。再叠一层位置约束:它不在负载均衡器的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、负载均衡器不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 deployment.md §2.1。 该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠负载均衡器挡」:进程满足「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且**设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。`DEBUG_ENABLED` 走环境变量而非启动命令,是为了让开关能在不发版的前提下从平台控制台改一次完成——启动命令保持静态。再叠一层位置约束:它不在负载均衡器的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、负载均衡器不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 docs/reference.md §3。
--- ---
@ -503,7 +503,7 @@ pub enum BotError {
- `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。**当前只记录启动期事件**:`src/main.rs` 共 5 处 `info!`/`warn!`(启动变量缺失降级到 setup 模式、启动横幅、debug 端点开启、JMAP 服务不可用降级×2)。请求级事件(Push 回调、Reconcile 拉取、渠道推送、LLM 耗时)**尚未实现**——`src/` 里除 `main.rs` 外没有任何 tracing 调用,也没有命名 span(无 `#[instrument]` / `span!`)。 - `tracing`(直接依赖)+ `tracing-subscriber`(fmt + EnvFilter)。**当前只记录启动期事件**:`src/main.rs` 共 5 处 `info!`/`warn!`(启动变量缺失降级到 setup 模式、启动横幅、debug 端点开启、JMAP 服务不可用降级×2)。请求级事件(Push 回调、Reconcile 拉取、渠道推送、LLM 耗时)**尚未实现**——`src/` 里除 `main.rs` 外没有任何 tracing 调用,也没有命名 span(无 `#[instrument]` / `span!`)。
- 指标(可选 `metrics` crate):Push 回调到达数、去重命中率、Redis Streams 积压深度(pending)、DLQ 条数、对账补差条数、JMAP 请求延迟、推送失败率。**当前未接入任何指标后端**(`metrics` 不在 `Cargo.toml`)。 - 指标(可选 `metrics` crate):Push 回调到达数、去重命中率、Redis Streams 积压深度(pending)、DLQ 条数、对账补差条数、JMAP 请求延迟、推送失败率。**当前未接入任何指标后端**(`metrics` 不在 `Cargo.toml`)。
- 优雅退出:**当前未实现**——`src/` 里没有信号处理(无 `tokio::signal` / `ctrl_c`),也没有 Streams pending 条目的 flush 逻辑;容器停机即终止进程。Redis 侧 `C-NO-STATEFUL-RECOVERY` 保证重启后从 Redis 重建,不依赖进程内状态。 - 优雅退出:**当前未实现**——`src/` 里没有信号处理(无 `tokio::signal` / `ctrl_c`),也没有 Streams pending 条目的 flush 逻辑;容器停机即终止进程。Redis 侧 `C-NO-STATEFUL-RECOVERY` 保证重启后从 Redis 重建,不依赖进程内状态。
- **可靠性目标与策略**(Streams ACK/retry、幂等去重、Push 重试、对账恢复、指标/告警、**≥99.9% 通知可用性及边界**)见 deployment.md §6.4/§6.5(`NFR-NOTIFY-SLA`)。 - **可靠性目标与策略**(Streams ACK/retry、幂等去重、Push 重试、对账恢复、指标/告警、**≥99.9% 通知可用性及边界**)见 README.md §6.3(`NFR-NOTIFY-SLA`)。
--- ---
@ -669,7 +669,7 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
**审计意见 → 收口(2026-09-26)** **审计意见 → 收口(2026-09-26)**
- 【应修-2】入队失败时 dedup 释放 best-effort 曾可能造成 24h 静默丢事件 → 已修复为 `claim_dedup_and_enqueue`(Lua 原子:`SET NX EX` 成功才 `XADD`),claim 与入队之间无中间失败窗口。 - 【应修-2】入队失败时 dedup 释放 best-effort 曾可能造成 24h 静默丢事件 → 已修复为 `claim_dedup_and_enqueue`(Lua 原子:`SET NX EX` 成功才 `XADD`),claim 与入队之间无中间失败窗口。
- 【应修-1】`Email/changes` 依赖 `newState` 续传,`jmap-client 0.4.2` 无 `upToId` → 已改为「同 `sinceState` 下逐次翻倍 `maxChanges` 扩窗(上限 4096),仅在无法扩窗时才推进 `new_state`」,避免按页推进时漏批;`newState` 语义已确认承重(`docs/opengaps.md` §2),真实 Stalwart 环境的回调验证往返与积压排空走的就是基线 → 增量 `/changes` → `newState` 这条路径(`docs/deployment.md` §4.1、§6.3.1)。 - 【应修-1】`Email/changes` 依赖 `newState` 续传,`jmap-client 0.4.2` 无 `upToId` → 已改为「同 `sinceState` 下逐次翻倍 `maxChanges` 扩窗(上限 4096),仅在无法扩窗时才推进 `new_state`」,避免按页推进时漏批;`newState` 语义已确认承重(`docs/opengaps.md` §2),真实 Stalwart 环境的回调验证往返与积压排空走的就是基线 → 增量 `/changes` → `newState` 这条路径(`README.md` §3.7、`README.md` §3.10)。
- 其余低风险项均已收口:未知 stream 的空值改为 `Err`(fail-closed,进重试/DLQ);`push:disable` 经 `forget_push_subscription` 清理验证码摘要键;`SET NX EX` TTL 下限收紧为 `.max(1)`;XAUTOCLAIM 空闲阈值按批大小缩放;无 payload 的畸形流条目由 `ack_malformed` 经 `XACK` 移出 PEL;CSPRNG 兜底 owner-token 改为「时间 + PID + 计数器」,不再使用常量。 - 其余低风险项均已收口:未知 stream 的空值改为 `Err`(fail-closed,进重试/DLQ);`push:disable` 经 `forget_push_subscription` 清理验证码摘要键;`SET NX EX` TTL 下限收紧为 `.max(1)`;XAUTOCLAIM 空闲阈值按批大小缩放;无 payload 的畸形流条目由 `ack_malformed` 经 `XACK` 移出 PEL;CSPRNG 兜底 owner-token 改为「时间 + PID + 计数器」,不再使用常量。
- 未排期待办(阶段 5「搜索 + 搜索片段」)**已收口(`bfe0fd8`)**:`/search` 走 `email_query`(`Filter::text`) + `SearchSnippet/get`,高亮降级与截断上限见 §2.3、§5.7;正文级高亮在锁定版本做不到(见 §2.3 备注)。阶段 5 待办已清零。 - 未排期待办(阶段 5「搜索 + 搜索片段」)**已收口(`bfe0fd8`)**:`/search` 走 `email_query`(`Filter::text`) + `SearchSnippet/get`,高亮降级与截断上限见 §2.3、§5.7;正文级高亮在锁定版本做不到(见 §2.3 备注)。阶段 5 待办已清零。
@ -688,7 +688,7 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
### 11.2 实时通信渠道(已决定,参见 deployment.md) ### 11.2 实时通信渠道(已决定,参见 deployment.md)
- 通道 = **JMAP Push HTTPS 回调 + 外部 Cron 对账与排空**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。Push 注册通过受保护的 `POST /api/push/register` 显式触发;外部 Cron 必须同时调 `/reconcile`(增量入队)与 `/worker`(排空并发通知)——**只调对账会让通知永远发不出去**(deployment.md §6.3.1)。 - 通道 = **JMAP Push HTTPS 回调 + 外部 Cron 对账与排空**(`C-NO-LONG-CONN`/`C-HTTPS-INBOUND`);EventSource/SSE/WebSocket 均**非目标**(`NG-POLLING-SSE`)。Push 注册通过受保护的 `POST /api/push/register` 显式触发;外部 Cron 必须同时调 `/reconcile`(增量入队)与 `/worker`(排空并发通知)——**只调对账会让通知永远发不出去**(deployment.md §6.3.1)。
- Stalwart 侧接入已在真实环境验证:内置角色具备 `PushSubscription` 权限,注册、验证往返与真实回调投递全部走通。回调重试次数与幂等键 TTL / 对账间隔的匹配属于运维调参项,不是验收门槛:`docs/deployment.md` §5 给重试建议、§6.3.1 给对账间隔取值、§7 说明幂等键 TTL 必须覆盖 TG rate-limit 回退上限。`docs/opengaps.md` 当前无未完成项。 - Stalwart 侧接入已在真实环境验证:内置角色具备 `PushSubscription` 权限,注册、验证往返与真实回调投递全部走通。回调重试次数与幂等键 TTL / 对账间隔的匹配属于运维调参项,不是验收门槛:`docs/reference.md` §9.3 给重试建议、README.md §3.10 给对账间隔取值、`docs/reference.md` §1 说明幂等键 TTL 必须覆盖 TG rate-limit 回退上限。`docs/opengaps.md` 当前无未完成项。
### 11.3 部署形式(已确认,架构相关) ### 11.3 部署形式(已确认,架构相关)
- **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。2026-09-28 用户已就此决策,决定边界与将来若要做时的改动面登记在 `docs/opengaps.md` §3。 - **已确认**:**单账户实现**(`REQ-SINGLE-ACCOUNT`);多账户暂用**多个 bot 实例**(各自 token/配置),**不做多账户单实例**(因此无需 chat→account 路由与 `JmapService` 池化)。2026-09-28 用户已就此决策,决定边界与将来若要做时的改动面登记在 `docs/opengaps.md` §3。

View File

@ -4,7 +4,7 @@
There are currently **no unfinished items** in this document: all four stage goals have been closed, and the only pending product decision (multiple accounts) has been determined not to be made by the user on 2026-09-28 (§3). Keep closing records to facilitate tracing why and how each gap was closed. There are currently **no unfinished items** in this document: all four stage goals have been closed, and the only pending product decision (multiple accounts) has been determined not to be made by the user on 2026-09-28 (§3). Keep closing records to facilitate tracing why and how each gap was closed.
The implemented interface and architecture design can be found in `docs/reference.md`, `docs/design.md`, `docs/deployment.md`, and the retired capabilities can be found in `docs/retired.md`. The verification baseline is based on the "P0 Access Control" section of `docs/design.md`. The implemented interface and architecture design can be found in `docs/reference.md`, `docs/design.md`, and the retired capabilities can be found in `docs/retired.md`. The verification baseline is based on the "P0 Access Control" section of `docs/design.md`.
## What you have to do ## What you have to do
@ -18,8 +18,8 @@ The TTL green light for real machines has already been passed using the Upstash
| --- | --- | --- | | --- | --- | --- |
| 1 | `newState` semantics for `Email/changes` | Closed (commit `269c8f6`) | | 1 | `newState` semantics for `Email/changes` | Closed (commit `269c8f6`) |
| 2 | TTL actual measurement | Closed (code closing `0890eb1` + real machine `ttl` two assertions run through) | | 2 | TTL actual measurement | Closed (code closing `0890eb1` + real machine `ttl` two assertions run through) |
| 3 | Telegram inbound / Stalwart `PushSubscription` joint debugging | Closed (real traffic has been driven, please see `docs/deployment.md` §4.1 for operation steps) | | 3 | Telegram inbound / Stalwart `PushSubscription` joint debugging | Closed (real traffic has been driven, please see `README.md` §3.7 for operation steps) |
| 4 | `/worker` is not in the deployment document | Closed (`docs/deployment.md` §6.3.1 has been added) | | 4 | `/worker` is not in the deployment document | Closed (`README.md` §3.10 has been added) |
Access control: `cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` all green; `cargo test --locked` **94 passed / 0 failed / 4 ignored** (2 TTL assertions out of 4 `#[ignore]` have been run with real Upstash: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**; the remaining 2 require real JMAP servers, this capability has been verified in staging joint debugging); documentation gate is 6/6 PASS, including 72 line number anchor non-null verifications. Access control: `cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` all green; `cargo test --locked` **94 passed / 0 failed / 4 ignored** (2 TTL assertions out of 4 `#[ignore]` have been run with real Upstash: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**; the remaining 2 require real JMAP servers, this capability has been verified in staging joint debugging); documentation gate is 6/6 PASS, including 72 line number anchor non-null verifications.
@ -93,12 +93,12 @@ The actual behavior of the code is worse than the entry describes: `Err` is thro
### 3. Multiple accounts (decided: not to do, 2026-09-28 user decision) ### 3. Multiple accounts (decided: not to do, 2026-09-28 user decision)
Single accounts are fixed by requirement (`REQ-SINGLE-ACCOUNT`) and never enter the stage target. The user made it clear on 2026-09-28 that "no multiple accounts will be made for the time being" and this gap was closed. `docs/design.md` §11.3, the `REQ-SINGLE-ACCOUNT` line of `docs/charter.md` and the environment variable table/requirements mapping table of `docs/deployment.md` were originally expressed in this way, and these three places have not been changed in this round. Single accounts are fixed by requirement (`REQ-SINGLE-ACCOUNT`) and never enter the stage target. The user made it clear on 2026-09-28 that "no multiple accounts will be made for the time being" and this gap was closed. `docs/design.md` §11.3, the `REQ-SINGLE-ACCOUNT` line of `docs/charter.md` and the environment variable table/requirements mapping table of `docs/reference.md` were originally expressed in this way, and these three places have not been changed in this round.
**This article determines the scope of coverage** **This article determines the scope of coverage**
- **No multi-account single instance** - One bot instance serves multiple JMAP accounts at the same time. Therefore there is no need for chat→account routing, per-domain/per-folder routing, or `JmapService` pooling. - **No multi-account single instance** - One bot instance serves multiple JMAP accounts at the same time. Therefore there is no need for chat→account routing, per-domain/per-folder routing, or `JmapService` pooling.
- **Existing path when second mailbox is required**: Deploy a second bot instance, each with independent `BOT_TOKEN` / `ACCOUNT_ID` / `JMAP_SESSION_URL` and Redis prefix. This is an existing capability that is documented and requires no new code (`docs/deployment.md`). - **Existing path when second mailbox is required**: Deploy a second bot instance, each with independent `BOT_TOKEN` / `ACCOUNT_ID` / `JMAP_SESSION_URL` and Redis prefix. This is an existing capability that is documented and requires no new code (`README.md` §6.1).
- **Trigger condition for re-opening**: When there is a routing requirement within a single instance such as "the same TG session needs to be routed according to the source mailbox", then dismantle `REQ-SINGLE-ACCOUNT`. - **Trigger condition for re-opening**: When there is a routing requirement within a single instance such as "the same TG session needs to be routed according to the source mailbox", then dismantle `REQ-SINGLE-ACCOUNT`.
**Minimum changes if necessary in the future** (The current situation has been verified in this round and will be used directly when disassembly is needed) **Minimum changes if necessary in the future** (The current situation has been verified in this round and will be used directly when disassembly is needed)
@ -112,6 +112,6 @@ This item is not skipped, but explicitly rejected: the deployment unit saved by
## Deployment document defects discovered and fixed in this round ## Deployment document defects discovered and fixed in this round
`docs/deployment.md` §6.3.1 Originally, only the scheduling example of `/reconcile` was given, and `/worker` - the only queue consumption entry in the entire code base - was described as "manual triggering of operation and maintenance" and was deliberately not included in the Worker whitelist. **Copying the deployment according to the document = emails continue to be queued and notifications are never sent out**. This is the reason why the joint debugging environment is backlogged for several days before being emptied manually. §6.3.1 The description of `/worker`’s scheduling steps, sequence requirements, batch limit and "204 cannot be used as a success signal" has been added. `README.md` §3.10 Originally, only the scheduling example of `/reconcile` was given, and `/worker` - the only queue consumption entry in the entire code base - was described as "manual triggering of operation and maintenance" and was deliberately not included in the Worker whitelist. **Copying the deployment according to the document = emails continue to be queued and notifications are never sent out**. This is the reason why the joint debugging environment is backlogged for several days before being emptied manually. `README.md` §3.10 The description of `/worker`’s scheduling steps, sequence requirements, batch limit and "204 cannot be used as a success signal" has been added.
At the same time, the anchor points of `/worker` in `docs/reference.md` and `docs/retired.md` are corrected: the original value `notify.rs:340` falls in the `reconcile` function body, and the anchor point audit will not report an error (the line exists and is not empty), but the semantics are wrong; the correct value is `notify.rs:390`. At the same time, the anchor points of `/worker` in `docs/reference.md` and `docs/retired.md` are corrected: the original value `notify.rs:340` falls in the `reconcile` function body, and the anchor point audit will not report an error (the line exists and is not empty), but the semantics are wrong; the correct value is `notify.rs:390`.

View File

@ -4,7 +4,7 @@
本文件当前**无未完成项**:四条阶段目标全部关闭,唯一悬置的产品决策(多账户)已由用户于 2026-09-28 定为不做(§3)。保留关闭记录,便于回溯每个缺口为什么关、怎么关的。 本文件当前**无未完成项**:四条阶段目标全部关闭,唯一悬置的产品决策(多账户)已由用户于 2026-09-28 定为不做(§3)。保留关闭记录,便于回溯每个缺口为什么关、怎么关的。
已实现的接口与架构设计见 `docs/reference.md`、`docs/design.md`、`docs/deployment.md`,已退役的能力见 `docs/retired.md`。验证基线以 `docs/design.md`「P0 门禁」段落为准。 已实现的接口与架构设计见 `docs/reference.md`、`docs/design.md`,已退役的能力见 `docs/retired.md`。验证基线以 `docs/design.md`「P0 门禁」段落为准。
## 待办事项 ## 待办事项
@ -18,8 +18,8 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
| --- | --- | --- | | --- | --- | --- |
| 1 | `Email/changes` 的 `newState` 语义 | 已关闭(提交 `269c8f6`) | | 1 | `Email/changes` 的 `newState` 语义 | 已关闭(提交 `269c8f6`) |
| 2 | TTL 实测 | 已关闭(代码收口 `0890eb1` + 真机 `ttl` 两条断言跑通) | | 2 | TTL 实测 | 已关闭(代码收口 `0890eb1` + 真机 `ttl` 两条断言跑通) |
| 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `docs/deployment.md` §4.1) | | 3 | Telegram 入站 / Stalwart `PushSubscription` 联调 | 已关闭(真实流量已驱动,操作步骤见 `README.md` §3.7) |
| 4 | `/worker` 未在部署文档里 | 已关闭(`docs/deployment.md` §6.3.1 已补) | | 4 | `/worker` 未在部署文档里 | 已关闭(`README.md` §3.10 已补) |
门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **94 passed / 0 failed / 4 ignored**(4 个 `#[ignore]` 里 2 个 TTL 断言已用真实 Upstash 跑过:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**;剩 2 个需要真实 JMAP 服务器,该能力已在 staging 联调验证);文档门 6/6 全绿,含 72 处行号锚点非空校验。 门禁:`cargo fmt --check` / `cargo check --locked` / `cargo clippy --locked --all-targets -- -D warnings` 全绿;`cargo test --locked` **94 passed / 0 failed / 4 ignored**(4 个 `#[ignore]` 里 2 个 TTL 断言已用真实 Upstash 跑过:`REDIS_TEST_URL=… cargo test --locked -- --ignored ttl` → **2 passed / 0 failed**;剩 2 个需要真实 JMAP 服务器,该能力已在 staging 联调验证);文档门 6/6 全绿,含 72 处行号锚点非空校验。
@ -93,12 +93,12 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
### 3. 多账户(已决定不做,用户于 2026-09-28 决定) ### 3. 多账户(已决定不做,用户于 2026-09-28 决定)
单账户由需求固定(`REQ-SINGLE-ACCOUNT`),从未进入阶段目标。用户于 2026-09-28 明确「暂时不做多账户」,此缺口关闭。`docs/design.md` §11.3、`docs/charter.md` 的 `REQ-SINGLE-ACCOUNT` 行与 `docs/deployment.md` 的环境变量表 / 需求映射表原本就按此表述,本轮未改这三处。 单账户由需求固定(`REQ-SINGLE-ACCOUNT`),从未进入阶段目标。用户于 2026-09-28 明确「暂时不做多账户」,此缺口关闭。`docs/design.md` §11.3、`docs/charter.md` 的 `REQ-SINGLE-ACCOUNT` 行与 `docs/reference.md` 的环境变量表 / 需求映射表原本就按此表述,本轮未改这三处。
**这条决定覆盖的范围** **这条决定覆盖的范围**
- **不做多账户单实例**——一个 bot 实例同时服务多个 JMAP 账户。因此不需要 chat→account 路由、按域 / 按文件夹路由、`JmapService` 池化。 - **不做多账户单实例**——一个 bot 实例同时服务多个 JMAP 账户。因此不需要 chat→account 路由、按域 / 按文件夹路由、`JmapService` 池化。
- **需要第二个邮箱时的既有路径**:部署第二个 bot 实例,各自独立 `BOT_TOKEN` / `ACCOUNT_ID` / `JMAP_SESSION_URL` 与 Redis 前缀。这是已文档化的既有能力,不需要新代码(`docs/deployment.md`)。 - **需要第二个邮箱时的既有路径**:部署第二个 bot 实例,各自独立 `BOT_TOKEN` / `ACCOUNT_ID` / `JMAP_SESSION_URL` 与 Redis 前缀。这是已文档化的既有能力,不需要新代码(`README.md` §6.1)。
- **重新开启的触发条件**:出现「同一个 TG 会话要按来源邮箱分路由」这类单实例内的路由需求时,再拆 `REQ-SINGLE-ACCOUNT`。 - **重新开启的触发条件**:出现「同一个 TG 会话要按来源邮箱分路由」这类单实例内的路由需求时,再拆 `REQ-SINGLE-ACCOUNT`。
**将来若真要做的最小改动面**(本轮核实过现状,供拆需求时直接用) **将来若真要做的最小改动面**(本轮核实过现状,供拆需求时直接用)
@ -112,6 +112,6 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
## 本轮发现并修复的部署文档问题 ## 本轮发现并修复的部署文档问题
`docs/deployment.md` §6.3.1 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。§6.3.1 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。 `README.md` §3.10 原来只给了 `/reconcile` 的调度示例,而 `/worker`——全代码库唯一的队列消费入口——被描述成「运维手工触发」,且刻意不在 Worker 白名单里。**照文档照抄部署 = 邮件持续进队列、通知永远发不出去**,这正是联调环境积压了数天才被手工排空的成因。`README.md` §3.10 已补上 `/worker` 的调度步骤、顺序要求、批量上限与「204 不能当成功信号」的说明。
同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:340` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:390`。 同时修正了 `docs/reference.md` 与 `docs/retired.md` 里 `/worker` 的锚点:原值 `notify.rs:340` 落在 `reconcile` 函数体内,锚点审计不会报错(该行存在且非空),但语义是错的;正确值是 `notify.rs:390`。

View File

@ -7,8 +7,8 @@
> >
> **This file carries no source line numbers, by design.** They are not part of the > **This file carries no source line numbers, by design.** They are not part of the
> verification: this file documents *which symbol* does *what*, and line numbers are > verification: this file documents *which symbol* does *what*, and line numbers are
> renumbered by every unrelated edit to the same file. `docs/deployment.md` keeps its > renumbered by every unrelated edit to the same file. `README.md` references code by symbol name only; the former
> line numbers, because that file is an operator runbook read mid-incident. > `docs/deployment.md` runbook (which kept line numbers) was merged into README in 2026-10.
> >
> **Maintenance responsibility.** Any change to the public API of `src/config.rs`, > **Maintenance responsibility.** Any change to the public API of `src/config.rs`,
> `src/state.rs`, `src/worker.rs`, or `src/notify.rs`, or to **any Redis key name or TTL**, > `src/state.rs`, `src/worker.rs`, or `src/notify.rs`, or to **any Redis key name or TTL**,
@ -659,7 +659,7 @@ dependency as OpenAI-compatible and documents it through `reqwest` instead.
## 9. Deployment platform details ## 9. Deployment platform details
The operator-facing sequence is in [`deployment.md`](deployment.md). This section keeps The operator-facing sequence is in [README.md](../README.md). This section keeps
platform-specific behavior and settings that are useful when configuring or diagnosing a platform-specific behavior and settings that are useful when configuring or diagnosing a
deployment. deployment.
@ -687,7 +687,7 @@ managed by the platform. The Dockerfile is for local container runs and Docker-b
The image has no Redis process, database, or persistent data volume. Runtime configuration is The image has no Redis process, database, or persistent data volume. Runtime configuration is
injected by the host; secrets do not enter build arguments or image layers. Docker-platform injected by the host; secrets do not enter build arguments or image layers. Docker-platform
health checks should call the HTTP endpoints described in [`deployment.md`](deployment.md). health checks should call the HTTP endpoints described in §9.4 below.
### 9.2 Backend configuration and secrets ### 9.2 Backend configuration and secrets

View File

@ -7,8 +7,8 @@
> >
> **根据设计,该文件不包含源行号。** 它们不属于 > **根据设计,该文件不包含源行号。** 它们不属于
> 验证:此文件记录*哪个符号*执行*什么*,行号是 > 验证:此文件记录*哪个符号*执行*什么*,行号是
> 对同一文件的每次不相关的编辑都会重新编号。 `docs/deployment.md` 保留其 > 对同一文件的每次不相关的编辑都会重新编号。 `README.md` 也仅用符号名引用代码;原
> 行号,因为该文件是操作员在事件中读取的运行手册。 > `docs/deployment.md` 运行手册(保留行号)已于 2026-10 合并入 README。
> >
> **维护责任。** 对 `src/config.rs` 公共 API 的任何更改, > **维护责任。** 对 `src/config.rs` 公共 API 的任何更改,
> `src/state.rs`、`src/worker.rs` 或 `src/notify.rs`,或 **任何 Redis 键名称或 TTL**, > `src/state.rs`、`src/worker.rs` 或 `src/notify.rs`,或 **任何 Redis 键名称或 TTL**,
@ -648,7 +648,7 @@ Redis 打嗝永远不会折叠窗口。仅在多实例上重复
## 9. 部署平台细节 ## 9. 部署平台细节
面向操作员的序列位于 [`deployment.md`](deployment.zh-CN.md) 中。本节保留 面向操作员的序列位于 [README.zh-CN.md](../README.zh-CN.md) 中。本节保留
配置或诊断时有用的特定于平台的行为和设置 配置或诊断时有用的特定于平台的行为和设置
部署。 部署。
@ -673,7 +673,7 @@ HostStack 部署不使用仓库的 Dockerfile;镜像构建和运行时由平
该映像没有 Redis 进程、数据库或持久数据卷。运行时配置是 该映像没有 Redis 进程、数据库或持久数据卷。运行时配置是
由主机注入;敏感凭据不会进入构建参数或镜像层。 Docker 平台 由主机注入;敏感凭据不会进入构建参数或镜像层。 Docker 平台
运行状况检查应调用 [`deployment.md`](deployment.zh-CN.md) 中描述的 HTTP 端点。 运行状况检查应调用下文 §9.4 中描述的 HTTP 端点。
### 9.2 后端配置与密钥 ### 9.2 后端配置与密钥

View File

@ -25,7 +25,7 @@
| Multi-step dialogue FSM (`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`) | Target design, not implemented | Current AI authorization only needs a Boolean + expiration time, multi-step is over-design | Redis TTL authorization state, key and TTL, see the AI authorization state section of `docs/reference.md` | | Multi-step dialogue FSM (`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`) | Target design, not implemented | Current AI authorization only needs a Boolean + expiration time, multi-step is over-design | Redis TTL authorization state, key and TTL, see the AI authorization state section of `docs/reference.md` |
| Split each layer of domain / channel / notification / util into independent directories (sub-module split plan) | Target form directory, not implemented | The amount of code has not reached the scale that needs to be split | For the actual structure, see the project structure section of `docs/design.md`: the domain layer is `src/domain.rs` + `src/domain/jmap/` (only `client.rs`); LLM is in `src/ai.rs`; notifications are in `src/notify.rs`; encryption and tool logic are inline in `src/state.rs` / `src/config.rs`, there is no independent util layer and no integration test directory | | Split each layer of domain / channel / notification / util into independent directories (sub-module split plan) | Target form directory, not implemented | The amount of code has not reached the scale that needs to be split | For the actual structure, see the project structure section of `docs/design.md`: the domain layer is `src/domain.rs` + `src/domain/jmap/` (only `client.rs`); LLM is in `src/ai.rs`; notifications are in `src/notify.rs`; encryption and tool logic are inline in `src/state.rs` / `src/config.rs`, there is no independent util layer and no integration test directory |
| Teloxide style outbound message delivery integration test | Target form test, not yet implemented | Dependent on teloxide test mode | Unit test of `src/channel.rs` (`#[test]`) | | Teloxide style outbound message delivery integration test | Target form test, not yet implemented | Dependent on teloxide test mode | Unit test of `src/channel.rs` (`#[test]`) |
| docker-compose `healthcheck` example (`message-weave health --addr ...`) | Deleted example | `src/main.rs` has no CLI subcommand resolution, the subcommand does not exist, and copying will fail (in the early days, there was also a problem of not having `curl` in the image, and now `curl` is built in) | `/ready` is detected by the platform ingress; for instructions, see `docs/deployment.md` Readiness Detection section | | docker-compose `healthcheck` example (`message-weave health --addr ...`) | Deleted example | `src/main.rs` has no CLI subcommand resolution, the subcommand does not exist, and copying will fail (in the early days, there was also a problem of not having `curl` in the image, and now `curl` is built in) | `/ready` is detected by the platform ingress; for instructions, see `docs/reference.md` §9.4 (readiness detection) |
| Redis ACL password for SPA admin credentials = `REDIS_URL` | Route not taken | Obfuscating infrastructure credentials with UI admin password; `.is_empty()` guard for `bootstrap_token` when Redis has no ACL password (TLS-only managed Redis) makes SPA permanent 401 | Use instead `CONFIG_ENCRYPTION_KEY` (32-byte high-entropy hex required for startup, constant time comparison) | | Redis ACL password for SPA admin credentials = `REDIS_URL` | Route not taken | Obfuscating infrastructure credentials with UI admin password; `.is_empty()` guard for `bootstrap_token` when Redis has no ACL password (TLS-only managed Redis) makes SPA permanent 401 | Use instead `CONFIG_ENCRYPTION_KEY` (32-byte high-entropy hex required for startup, constant time comparison) |
@ -101,7 +101,7 @@ These names never exist in the code. Some of them appear in the directory tree o
| `/flag` / `/unseen` / Send command | Not implemented | Currently 6 intents recognized (Help/Consent/Summary/Search/General Messages/Unrecognized); of which `/search` is implemented (`worker.rs:680`) | None | | `/flag` / `/unseen` / Send command | Not implemented | Currently 6 intents recognized (Help/Consent/Summary/Search/General Messages/Unrecognized); of which `/search` is implemented (`worker.rs:680`) | None |
| `Identity` concept | Not implemented | Account identification only relies on `ACCOUNT_ID`, no identity layer abstraction | None | | `Identity` concept | Not implemented | Account identification only relies on `ACCOUNT_ID`, no identity layer abstraction | None |
| Extract `RUN_MODE` into a separate configuration file | Fictitious splitting scheme | `RUN_MODE` was once read by `config.rs` and verified in `src/main.rs`. **This variable and the verification block have been deleted together** (see §4); there has never been such a function as `validate_env_or_exit` | None | | Extract `RUN_MODE` into a separate configuration file | Fictitious splitting scheme | `RUN_MODE` was once read by `config.rs` and verified in `src/main.rs`. **This variable and the verification block have been deleted together** (see §4); there has never been such a function as `validate_env_or_exit` | None |
| docker-compose `message-weave health --addr` example | fictitious command | Applies no CLI subcommand; health check endpoints are `GET /healthz` and `GET /ready` | See the Health-check table in `docs/deployment.md` | | docker-compose `message-weave health --addr` example | fictitious command | Applies no CLI subcommand; health check endpoints are `GET /healthz` and `GET /ready` | See the Health-check section in `docs/reference.md` §9.4 |
| Early design debate questions (message format/long message threshold/attachment policy/Identity/monitoring/LLM provider/circuit breaker, etc. 17 items) | Answered by code | The answer is given by the code that has been implemented and is no longer a pending item | See the "Early questions answered by code" section of `docs/design.md` | | Early design debate questions (message format/long message threshold/attachment policy/Identity/monitoring/LLM provider/circuit breaker, etc. 17 items) | Answered by code | The answer is given by the code that has been implemented and is no longer a pending item | See the "Early questions answered by code" section of `docs/design.md` |
## 3. Deleted documents ## 3. Deleted documents
@ -109,7 +109,7 @@ These names never exist in the code. Some of them appear in the directory tree o
| Entry | Type | Reason | Alternative or Current Status | | Entry | Type | Reason | Alternative or Current Status |
|---|---|---|---| |---|---|---|---|
| `docs/todo.md` | Superseded (deleted) | Unclear structure, overlapping with design/deployment, and once carrying historical narratives such as "this round has been closed" | First replaced by `docs/roadmap.md`, which was renamed and narrowed to `docs/opengaps.md` on 2026-09-29 (only unfinished/unverified gaps, blockages and next-stage goals are included) | | `docs/todo.md` | Superseded (deleted) | Unclear structure, overlapping with design/deployment, and once carrying historical narratives such as "this round has been closed" | First replaced by `docs/roadmap.md`, which was renamed and narrowed to `docs/opengaps.md` on 2026-09-29 (only unfinished/unverified gaps, blockages and next-stage goals are included) |
| Temporary handover files in the root directory (no such files are left) | Temporary handover files, **never entered the git history** | Handover content should be returned to the permanent document, no temporary files in the root directory are left | Contents are merged into `docs/design.md`, `docs/deployment.md`, `docs/reference.md`, `docs/opengaps.md` | | Temporary handover files in the root directory (no such files are left) | Temporary handover files, **never entered the git history** | Handover content should be returned to the permanent document, no temporary files in the root directory are left | Contents are merged into `docs/design.md`, `docs/reference.md`, `docs/opengaps.md` |
--- ---

View File

@ -25,7 +25,7 @@
| 多步对话 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`) | 目标设计,未落地 | 当前 AI 授权只需一个布尔+过期时间,多步态属过度设计 | Redis TTL 授权态,键与 TTL 见 `docs/reference.md` 的 AI 授权态一节 | | 多步对话 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`) | 目标设计,未落地 | 当前 AI 授权只需一个布尔+过期时间,多步态属过度设计 | Redis TTL 授权态,键与 TTL 见 `docs/reference.md` 的 AI 授权态一节 |
| 把领域 / 渠道 / 通知 / util 各层拆成独立目录(子模块拆分方案) | 目标形态目录,未落地 | 代码量未到需要拆分的规模 | 实际结构见 `docs/design.md` 的工程结构一节:领域层是 `src/domain.rs` + `src/domain/jmap/`(仅 `client.rs`);LLM 在 `src/ai.rs`;通知在 `src/notify.rs`;加密与工具逻辑内联在 `src/state.rs` / `src/config.rs`,没有独立的 util 层,也没有集成测试目录 | | 把领域 / 渠道 / 通知 / util 各层拆成独立目录(子模块拆分方案) | 目标形态目录,未落地 | 代码量未到需要拆分的规模 | 实际结构见 `docs/design.md` 的工程结构一节:领域层是 `src/domain.rs` + `src/domain/jmap/`(仅 `client.rs`);LLM 在 `src/ai.rs`;通知在 `src/notify.rs`;加密与工具逻辑内联在 `src/state.rs` / `src/config.rs`,没有独立的 util 层,也没有集成测试目录 |
| teloxide 风格的出站消息派发集成测试 | 目标形态测试,未落地 | 依附 teloxide 测试模式 | `src/channel.rs` 的单元测试(`#[test]`) | | teloxide 风格的出站消息派发集成测试 | 目标形态测试,未落地 | 依附 teloxide 测试模式 | `src/channel.rs` 的单元测试(`#[test]`) |
| docker-compose `healthcheck` 示例(`message-weave health --addr ...`) | 已删除的示例 | `src/main.rs` 无 CLI 子命令解析,该子命令不存在,照抄必失败(早期还叠加了镜像内无 `curl` 的问题,现已内置 `curl`) | 由平台 ingress 探测 `/ready`;说明见 `docs/deployment.md` 的就绪探测一节 | | docker-compose `healthcheck` 示例(`message-weave health --addr ...`) | 已删除的示例 | `src/main.rs` 无 CLI 子命令解析,该子命令不存在,照抄必失败(早期还叠加了镜像内无 `curl` 的问题,现已内置 `curl`) | 由平台 ingress 探测 `/ready`;说明见 `docs/reference.md` §9.4 的就绪探测一节 |
| SPA 管理凭据 = `REDIS_URL` 的 Redis ACL 密码 | 未采用路线 | 混淆基础设施凭据与 UI 管理密码;Redis 无 ACL 密码(TLS-only 托管 Redis)时 `bootstrap_token` 的 `.is_empty()` 守卫让 SPA 永久 401 | 改用 `CONFIG_ENCRYPTION_KEY`(启动必填的 32 字节高熵 hex,常数时间比较) | | SPA 管理凭据 = `REDIS_URL` 的 Redis ACL 密码 | 未采用路线 | 混淆基础设施凭据与 UI 管理密码;Redis 无 ACL 密码(TLS-only 托管 Redis)时 `bootstrap_token` 的 `.is_empty()` 守卫让 SPA 永久 401 | 改用 `CONFIG_ENCRYPTION_KEY`(启动必填的 32 字节高熵 hex,常数时间比较) |
@ -101,7 +101,7 @@
| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:680`) | 无 | | `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:680`) | 无 |
| `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 | | `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 |
| 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 | | 把 `RUN_MODE` 抽成独立配置文件 | 虚构拆分方案 | `RUN_MODE` 曾由 `config.rs` 读取并在 `src/main.rs` 校验取值,**该变量与校验块现已一并删除**(见 §4);从未存在 `validate_env_or_exit` 这类函数 | 无 |
| docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 | | docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/reference.md` §9.4 的 Health-check 节 |
| 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 | | 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 |
## 3. 已删除的文档 ## 3. 已删除的文档
@ -109,7 +109,7 @@
| 条目 | 类型 | 原因 | 替代或现状 | | 条目 | 类型 | 原因 | 替代或现状 |
|---|---|---|---| |---|---|---|---|
| `docs/todo.md` | 已取代(已删除) | 结构不清,与 design/deployment 重叠,且曾承载「本轮已收口」这类历史叙述 | 先由 `docs/roadmap.md` 取代,后者于 2026-09-29 更名并收窄为 `docs/opengaps.md`(只放仍未完成/未验证的缺口、阻塞与下一阶段目标) | | `docs/todo.md` | 已取代(已删除) | 结构不清,与 design/deployment 重叠,且曾承载「本轮已收口」这类历史叙述 | 先由 `docs/roadmap.md` 取代,后者于 2026-09-29 更名并收窄为 `docs/opengaps.md`(只放仍未完成/未验证的缺口、阻塞与下一阶段目标) |
| 根目录临时交接件(不留此类文件) | 临时交接件,**从未进入 git 历史** | 交接内容应归位到常驻文档,不留根目录临时件 | 内容并入 `docs/design.md`、`docs/deployment.md`、`docs/reference.md`、`docs/opengaps.md` | | 根目录临时交接件(不留此类文件) | 临时交接件,**从未进入 git 历史** | 交接内容应归位到常驻文档,不留根目录临时件 | 内容并入 `docs/design.md`、`docs/reference.md`、`docs/opengaps.md` |
--- ---

View File

@ -12,7 +12,7 @@
# POST /worker → 204 成功 / 400(body 非法)/ 401 / 503(带 Retry-After) # POST /worker → 204 成功 / 400(body 非法)/ 401 / 503(带 Retry-After)
# 因此看到 204 是正常的,不要当成失败。 # 因此看到 204 是正常的,不要当成失败。
# #
# 详设计说明见 docs/deployment.md 第 6.3 节与第 6.3.1 节。 # 详设计说明见 README.md 第 3.10 节(调度与探活)。
# #
# --- 凭据只从环境变量或命令行 flag 读取 --- # --- 凭据只从环境变量或命令行 flag 读取 ---
# 不读 .env、不读配置文件、不写盘(唯一例外是可选的 --log-file / MW_LOG_FILE 运行日志)。 # 不读 .env、不读配置文件、不写盘(唯一例外是可选的 --log-file / MW_LOG_FILE 运行日志)。

View File

@ -46,7 +46,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md', 'docs/design.zh-CN.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md', 'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md', 'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md', 'docs/charter.md', 'docs/charter.zh-CN.md',

View File

@ -42,7 +42,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md', 'docs/design.zh-CN.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md', 'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md', 'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md', 'docs/charter.md', 'docs/charter.zh-CN.md',

View File

@ -110,11 +110,11 @@ EXEMPT_EXTERNAL = ("stalwartlabs/",)
# have it". Each entry carries its justification so the list cannot grow # have it". Each entry carries its justification so the list cannot grow
# silently. # silently.
CONVENTIONAL = { CONVENTIONAL = {
"docker-compose.yml": "docker compose's default filename; deployment.md " "docker-compose.yml": "docker compose's default filename; README.md "
"states the repo ships no compose file and gives the " "states the repo ships no compose file and gives the "
"image-based command instead", "image-based command instead",
"hoststack.yml": "second filename HostStack's yaml-parser accepts alongside " "hoststack.yml": "second filename HostStack's yaml-parser accepts alongside "
"hoststack.yaml; deployment.md names both while the repo " "hoststack.yaml; README.md names both while the repo "
"ships only the .yaml variant", "ships only the .yaml variant",
} }

View File

@ -14,7 +14,7 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md", DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md",
"docs/design.zh-CN.md", "docs/deployment.md", "docs/deployment.zh-CN.md", "docs/design.zh-CN.md",
"docs/reference.md", "docs/reference.zh-CN.md", "docs/retired.md", "docs/reference.md", "docs/reference.zh-CN.md", "docs/retired.md",
"docs/retired.zh-CN.md", "docs/opengaps.md", "docs/opengaps.zh-CN.md", "docs/retired.zh-CN.md", "docs/opengaps.md", "docs/opengaps.zh-CN.md",
"docs/charter.md", "docs/charter.zh-CN.md", "docs/charter.md", "docs/charter.zh-CN.md",
@ -27,7 +27,7 @@ SEC = re.compile(r"§(\d+(?:\.\d+)?)")
# an explicit target file sitting immediately before the §ref # an explicit target file sitting immediately before the §ref
TARGET_BEFORE = re.compile( TARGET_BEFORE = re.compile(
r"(?:docs/)?(AGENTS\.md|README\.md|README\.zh-CN\.md|design(?:\.zh-CN)?\.md|" r"(?:docs/)?(AGENTS\.md|README\.md|README\.zh-CN\.md|design(?:\.zh-CN)?\.md|"
r"deployment(?:\.zh-CN)?\.md|reference(?:\.zh-CN)?\.md|retired(?:\.zh-CN)?\.md|opengaps(?:\.zh-CN)?\.md|charter(?:\.zh-CN)?\.md)\s*[`\s]*" r"reference(?:\.zh-CN)?\.md|retired(?:\.zh-CN)?\.md|opengaps(?:\.zh-CN)?\.md|charter(?:\.zh-CN)?\.md)\s*[`\s]*"
r"§(\d+(?:\.\d+)?)") r"§(\d+(?:\.\d+)?)")

View File

@ -3,7 +3,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md', 'docs/design.zh-CN.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md', 'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md', 'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md', 'docs/charter.md', 'docs/charter.zh-CN.md',

View File

@ -2,7 +2,7 @@ import re, sys
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/design.zh-CN.md', 'docs/deployment.md', 'docs/deployment.zh-CN.md', 'docs/design.zh-CN.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md', 'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md', 'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md', 'docs/charter.md', 'docs/charter.zh-CN.md',