docs: fix facts that the code contradicts, in both languages

SAFE_ROUTES has 19 entries (backends.js), not 18. reference.md and
reference.zh-CN.md both said '18 entries'; both now say 19.

reference §4 said the /api/enabled and /api/admin/session calls are
'admin-session Bearer-auth'd'. The code says otherwise: config_authorized
(notify.rs:423) accepts the shared worker token OR a valid admin session,
and create_admin_session (notify.rs:1187) is gated by worker_authorized
against the admin credential CONFIG_ENCRYPTION_KEY — it is what mints the
session, so it cannot be session-auth'd. Both languages now state the two
distinct credentials and why adding /api/enabled opens no new credential
path.

deployment §10.8 said every request-bearing entry point, listing /healthz
and /api/*, calls refresh_business_config. The actual 10 call sites cover
8 distinct paths (/ready, /worker, /reconcile, /push/jmap, /webhook/tg,
/api/config, /api/business-config, /api/telegram/register-webhook). GET
/healthz is a static 200 "ok" and never refreshes; /api/status,
/api/enabled, /api/push/register, /api/push/disable and the
/api/admin/session pair do not either. Both languages now list the real
set and name the exclusions.

design.md §5.7 listed the search trigger prefixes as English
'search'/'find'/'retrieval'; worker.rs:689 accepts the Chinese prefixes
搜索/查找/检索 and the help text states English aliases are not supported.
The two user-facing strings were also mistranslated: the empty-query
guidance prompt is 请提供搜索关键词,例如:/search 发票 (worker.rs:623)
and the no-match reply is 没有找到匹配「<query>」的邮件。
(worker.rs:792). The degraded-rendering marker is likewise a Chinese
string. SEARCH_SUBJECT_MAX=120 / SEARCH_PREVIEW_MAX=160 /
SEARCH_LIMIT=10 verified unchanged.

design.md §5.6/§5.7 were machine-translated from Chinese back into
English and were broken: 'the first 300 if it fails. characters', a
stray closing ** mid-sentence, '(none) deny_unknown_fields)' with a
mismatched paren, and 5 zero-width-space artefacts. charter.md:168 had a
broken '**Tables, lists, or values may not be copied across documents. **'
and opengaps.md had 4. All ZWSPs removed from docs/.

deployment.zh-CN.md §10.5/§10.6 were thin mistranslations: 'Worker 域'
for 'Worker URL', the debug-token requirement dropped entirely, and a
mangled '两者都使用用于验证部署的可用和版本'. Rewritten against the EN.

Also replaced a worker.rs:227 line anchor with the MetadataWorker doc
comment; that drops the repo's line-refs from 72 to 71, so new text no
longer adds to the drift surface.

No code changes; GATE-DOCS 6/6 (71 anchors, 455 section refs, 727 path
refs).
This commit is contained in:
anonsaber 2026-10-01 18:47:05 +08:00
parent 8e92c9de4e
commit 5f182f97b0
7 changed files with 198 additions and 182 deletions

View File

@ -165,7 +165,7 @@ Connected. Real environment integration tests must be marked with `#[ignore]` an
## 7. Document boundaries ## 7. Document boundaries
Answer only one question per document; **Tables, lists, or values ​​may not be copied across documents. ** Answer only one question per document; **tables, lists and values must not be duplicated across documents.**
| Documentation | What to answer | Scope of authority | | Documentation | What to answer | Scope of authority |
|---|---|---| |---|---|---|

View File

@ -267,11 +267,16 @@ business authentication.
### 10.8 Configuration convergence across origins ### 10.8 Configuration convergence across origins
One SPA edit writes once to Redis. The origins converge **lazily, at the request boundary**: every One SPA edit writes once to Redis. The origins converge **lazily, at the request boundary**: each
request-bearing entry point (`/worker`, `/reconcile`, `/healthz`, `/push/jmap`, `/api/*`) calls entry point that calls `refresh_business_config` re-reads the global revision from Redis and
`refresh_business_config`, which reads the global revision from Redis and returns immediately when returns immediately when `remote_revision <= local_revision`. There is no polling loop and no
`remote_revision <= local_revision`. There is no polling loop and no watch, so an origin that watch, so an origin that receives no traffic keeps the configuration it already had.
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 **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 above is an early exit, not a retry guard. When `build_worker` fails for one origin — a JMAP

View File

@ -221,17 +221,18 @@ HTTPS URL,之后才进行路由校验或转发。Worker 使用“404”拒绝
### 10.5 回调地址 ### 10.5 回调地址
使用网关时,将Telegram的webhook和Stalwart的push回调设置给Worker 使用网关时,将 Telegram 的 webhook 和 Stalwart 的 push 回调都设置为 Worker URL。
域。外部调度程序可以对转发路径使用相同的 URL。引导程序和 外部调度程序(cron/`systemd`/K8s CronJob)对已转发的路径使用相同的 URL。
诊断端点仍然需要后端源。 引导程序必须在后端源上执行;诊断端点需要后端源**和** debug token——Worker 都不转发它们。
### 10.6 网关健康状态 ### 10.6 网关健康状态
`GET /healthz-worker` 报告 Worker 版本和后端可用性。两者都使用 `GET /healthz-worker` 报告 Worker 的版本(`version`)和健康的 origin 数量
用于验证部署的“可用”和“版本”; `status: no-backends` 可以返回 (`available`)。两者都用于验证部署:`version` 告诉你当前是哪次部署在应答,
当未配置有效源时,HTTP 200。 `available` 告诉你有多少 origin 健康。`status: no-backends` 在返回 HTTP 200 时
是**正确**的。
### 10.7 功能边界
Worker 不是 Redis 代理,也不会使后端无状态;共享外部 Worker 不是 Redis 代理,也不会使后端无状态;共享外部
Redis 仍然需要。它不添加数据库、长期连接或第二层 Redis 仍然需要。它不添加数据库、长期连接或第二层
@ -239,10 +240,14 @@ Redis 仍然需要。它不添加数据库、长期连接或第二层
### 10.8 多源站之间的配置收敛 ### 10.8 多源站之间的配置收敛
SPA 的一次编辑只写入 Redis 一次。各源站**惰性收敛,发生在请求边界上**:每个带请求的入口 SPA 的一次编辑只写入 Redis 一次。各源站**惰性收敛,发生在请求边界上**:每个调用
(`/worker`、`/reconcile`、`/healthz`、`/push/jmap`、`/api/*`)都会调用 `refresh_business_config` 的入口都会先从 Redis 读全局版本,当 `remote_revision <= local_revision`
`refresh_business_config`,它先从 Redis 读全局版本,当 `remote_revision <= local_revision` 时 时立即返回。没有轮询循环,也没有 watch,所以一台完全收不到流量的源站会一直保留它原有的配置。
立即返回。没有轮询循环,也没有 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` / 重试保护。当某台源站的 `build_worker` 失败——JMAP session URL 不可达,或 `llm_api_key` /

View File

@ -71,21 +71,21 @@ Source: Read directly from the `stalwartlabs/jmap-client` warehouse `stalwartlab
### 2.2 Module structure (`src/`) ### 2.2 Module structure (`src/`)
``` ```
lib.rs 顶层:URI / Method / DataType / PushObject / Error 枚举 lib.rs 顶层:URI / Method / DataType / PushObject / Error 枚举
client.rs Client:认证、connect、session、send_request、event_source、ws client.rs Client:认证、connect、session、send_request、event_source、ws
core/ request / response / get / set / query / query_changes / changes / error / session core/ request / response / get / set / query / query_changes / changes / error / session
email/ mod + helpers(email_get/email_query/email_import/email_copy/email_set/email_parse…) email/ mod + helpers(email_get/email_query/email_import/email_copy/email_set/email_parse…)
email_submission/ helpers(email_submission_get/query/set → 发送邮件 + 状态查询) email_submission/ helpers(email_submission_get/query/set → 发送邮件 + 状态查询)
mailbox/ helpers(mailbox_create/query/get/set/destroy…) mailbox/ helpers(mailbox_create/query/get/set/destroy…)
thread/ thread/get thread/ thread/get
identity/ Identity(发件身份)get/set identity/ Identity(发件身份)get/set
blob/ blob 上传/复制(附件) blob/ blob 上传/复制(附件)
sieve/ SieveScript(服务端过滤脚本) sieve/ SieveScript(服务端过滤脚本)
vacation_response/ vacation_response/
principal/ Principal(账户/共享) principal/ Principal(账户/共享)
push_subscription/ PushSubscription(HTTP push 回调注册) push_subscription/ PushSubscription(HTTP push 回调注册)
event_source/ SSE 流:mod / parser / stream event_source/ SSE 流:mod / parser / stream
client_ws/ WebSocket 客户端(feature = "websockets") client_ws/ WebSocket 客户端(feature = "websockets")
``` ```
### 2.3 Key capabilities mapped to this Bot ### 2.3 Key capabilities mapped to this Bot
@ -111,14 +111,14 @@ client_ws/ WebSocket 客户端(feature = "websockets")
### 2.5 Error model (`Error` enumeration) ### 2.5 Error model (`Error` enumeration)
``` ```
Transport(reqwest::Error) 网络/TLS/超时 Transport(reqwest::Error) 网络/TLS/超时
Parse(serde_json::Error) 序列化 Parse(serde_json::Error) 序列化
Internal(String) 客户端内部 Internal(String) 客户端内部
Problem(Box<ProblemDetails>) JMAP problem-details Problem(Box<ProblemDetails>) JMAP problem-details
Server(String) Server(String)
Method(MethodError) JMAP method-level 错误(NotJSON/Forbidden/RateLimit/StateMismatch…) Method(MethodError) JMAP method-level 错误(NotJSON/Forbidden/RateLimit/StateMismatch…)
Set(SetError<String>) /set 级别错误(每条记录的 creation/update/destroy 失败) Set(SetError<String>) /set 级别错误(每条记录的 creation/update/destroy 失败)
WebSocket(...) ws 错误(feature 开启时) 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.
@ -175,37 +175,37 @@ Candidate comparison (`teloxide` / `grammers` / old `telegram-bot`), 6 reasons w
### 5.1 High-level topology ### 5.1 High-level topology
``` ```
┌───────────────────────────────────────────────────┐ ┌───────────────────────────────────────────────────┐
│ Telegram │ │ Telegram │
└───────────────────────┬───────────────────────────┘ └───────────────────────┬───────────────────────────┘
Webhook(HTTPS) │ Webhook(HTTPS) │
┌───────────────────────▼───────────────────────────┐ ┌───────────────────────▼───────────────────────────┐
│ HTTP 入口 (axum,单端口 PORT) │ │ HTTP 入口 (axum,单端口 PORT) │
│ POST /webhook/tg · POST /push/jmap · │ │ POST /webhook/tg · POST /push/jmap · │
│ POST /reconcile · GET /healthz /ready │ │ POST /reconcile · GET /healthz /ready │
│ GET / + admin session + config APIs (管理 SPA) │ │ GET / + admin session + config APIs (管理 SPA) │
└───────────────────────┬───────────────────────────┘ └───────────────────────┬───────────────────────────┘
命令 │ 通知 ↓(Redis Streams worker) 命令 │ 通知 ↓(Redis Streams worker)
┌───────────────────────▼───────────────────────────┐ ┌───────────────────────▼───────────────────────────┐
│ bot 层 (handlers) │ │ bot 层 (handlers) │
│ /list /read /send /search /folders /flag ... │ │ /list /read /send /search /folders /flag ... │
└───────────────────────┬───────────────────────────┘ └───────────────────────┬───────────────────────────┘
领域 │ 领域 │
┌───────────────────────▼───────────────────────────┐ ┌───────────────────────▼───────────────────────────┐
│ jmap 适配层 (JmapService) │ │ jmap 适配层 (JmapService) │
│ list_folders/list_emails/read_email/send_email… │ │ list_folders/list_emails/read_email/send_email… │
│ + sinceState 缓存 → 外部 Redis (MOD-SINCESTATE) │ │ + sinceState 缓存 → 外部 Redis (MOD-SINCESTATE) │
└───────────────────────┬───────────────────────────┘ └───────────────────────┬───────────────────────────┘
JMAP │ HTTPS(短请求) JMAP │ HTTPS(短请求)
┌───────────────────────▼───────────────────────────┐ ┌───────────────────────▼───────────────────────────┐
│ Stalwart JMAP Server │ │ Stalwart JMAP Server │
│ session_url / email/* / mailbox/* / submission/* │ │ session_url / email/* / mailbox/* / submission/* │
└───────────────────────▲───────────────────────────┘ └───────────────────────▲───────────────────────────┘
Push 回调(HTTPS)│ + 外部 Cron 对账(FLOW-RECONCILE) Push 回调(HTTPS)│ + 外部 Cron 对账(FLOW-RECONCILE)
┌───────────────────────┴───────────────────────────┐ ┌───────────────────────┴───────────────────────────┐
│ push handler → Redis Streams(MOD-STREAMS) │ │ push handler → Redis Streams(MOD-STREAMS) │
│ 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**.
@ -216,47 +216,47 @@ Candidate comparison (`teloxide` / `grammers` / old `telegram-bot`), 6 reasons w
- **Status quo**: The first (and currently only) channel is Telegram. The goal is to retain the ability to expand DingTalk/Feishu in parallel, but not to achieve it ahead of time. - **Status quo**: The first (and currently only) channel is Telegram. The goal is to retain the ability to expand DingTalk/Feishu in parallel, but not to achieve it ahead of time.
- **Principle**: Decoupling the domain layer and channel layer - Mail/JMAP/AI/intention state machine ** must not rely on Telegram type**. - **Principle**: Decoupling the domain layer and channel layer - Mail/JMAP/AI/intention state machine ** must not rely on Telegram type**.
- **Abstract (not over-engineered)**: No empty traits are reserved. `src/channel.rs` had three zero implementations - **Abstract (not over-engineered)**: No empty traits are reserved. `src/channel.rs` had three zero implementations
`#[expect(dead_code)]` Placeholder trait (`Channel` / `Notifier` / `MessageAdapter`, comment readme `#[expect(dead_code)]` Placeholder trait (`Channel` / `Notifier` / `MessageAdapter`, comment readme
"Stable ID + Phase 0 placeholder"), **Deleted** (see `docs/retired.md` for registration) - Actual Telegram outbound "Stable ID + Phase 0 placeholder"), **Deleted** (see `docs/retired.md` for registration) - Actual Telegram outbound
`channel::telegram::TelegramClient::send_text` (self-developed by `reqwest`), by `worker.rs` `channel::telegram::TelegramClient::send_text` (self-developed by `reqwest`), by `worker.rs`
`MetadataWorker` and `notify.rs` are held directly and are never passed through these three traits. The command is parsed in `MetadataWorker` and `notify.rs` are held directly and are never passed through these three traits. The command is parsed in
`parse_intent` (`src/worker.rs`), rendering logic is scattered in `src/worker.rs` / `src/notify.rs`. `parse_intent` (`src/worker.rs`), rendering logic is scattered in `src/worker.rs` / `src/notify.rs`.
What is passed between the domain and the channel is the domain `Notification` (`src/domain.rs`), and the channel only adapts at the edge. What is passed between the domain and the channel is the domain `Notification` (`src/domain.rs`), and the channel only adapts at the edge.
- **Constraints**: - **Constraints**:
- The public interface of `jmap` / `llm` / intent state machine / `notify::core` only accepts/returns domain types; - The public interface of `jmap` / `llm` / intent state machine / `notify::core` only accepts/returns domain types;
- Specific channel SDK types must not appear in the domain layer (the channel layer does not currently introduce third-party Bot frameworks, see §4); - Specific channel SDK types must not appear in the domain layer (the channel layer does not currently introduce third-party Bot frameworks, see §4);
- Add new channel = new adapter + assembly, do not change the domain layer. When abstraction is really needed, add it with the implementation and leave no empty traits. - Add new channel = new adapter + assembly, do not change the domain layer. When abstraction is really needed, add it with the implementation and leave no empty traits.
- **What not to do**: Do not define a polymorphic configuration registry, do not abstract the "channel capability matrix" in advance, do not build a plugins mechanism; evolve on demand (YAGNI). - **What not to do**: Do not define a polymorphic configuration registry, do not abstract the "channel capability matrix" in advance, do not build a plugins mechanism; evolve on demand (YAGNI).
### 5.3 Running model (short request, no long connection) ### 5.3 Running model (short request, no long connection)
- Single binary `message-weave`, `#[tokio::main]`. - Single binary `message-weave`, `#[tokio::main]`.
- On startup: - On startup:
1. Load configuration (`config::Config`). 1. Load configuration (`config::Config`).
2. Construct `JmapService` (`Client::connect` completes session parsing, account_id caching, mailbox role→id mapping warm-up); sinceState is restored from external Redis (`MOD-SINCESTATE`). 2. Construct `JmapService` (`Client::connect` completes session parsing, account_id caching, mailbox role→id mapping warm-up); sinceState is restored from external Redis (`MOD-SINCESTATE`).
3. Start the **HTTP portal** (axum, single port `PORT`): `/webhook/tg`, `/push/jmap`, `/reconcile`, `/healthz`, `/ready`; three of the write paths first pass the `SAF-AUTH-*` portal authentication (fail-closed, §7.3), `/healthz`, `/ready` For the public probe (`SAF-PROBE-PUBLIC`); `/ready` has done end-to-end detection (configuration + Redis + outbound read-only detection JMAP session `GET` and TG `getMe`, each 3s, parallel, about 3s at worst). 3. Start the **HTTP portal** (axum, single port `PORT`): `/webhook/tg`, `/push/jmap`, `/reconcile`, `/healthz`, `/ready`; three of the write paths first pass the `SAF-AUTH-*` portal authentication (fail-closed, §7.3), `/healthz`, `/ready` For the public probe (`SAF-PROBE-PUBLIC`); `/ready` has done end-to-end detection (configuration + Redis + outbound read-only detection JMAP session `GET` and TG `getMe`, each 3s, parallel, about 3s at worst).
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 deployment.md §10 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 deployment.md §0 / §8.2)**:
- **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.
- **Log and Redis write content constraints** (`SAF-LOG-PURITY`): Only structured events, counts, timestamps, desensitized summaries; **Prohibited** key original text, JMAP email body, AI request/response, attachment content. - **Log and Redis write content constraints** (`SAF-LOG-PURITY`): Only structured events, counts, timestamps, desensitized summaries; **Prohibited** key original text, JMAP email body, AI request/response, attachment content.
- **It is prohibited to rely on in-process status for production recovery** (`C-NO-STATEFUL-RECOVERY`): Restart and continuation (deduplication, sinceState, Streams breakpoints, circuit breaker counts, sessions) are all implemented by external Redis + JMAP reconciliation; the in-process cache is only for performance optimization, and the loss must be safe and reentrant. - **It is prohibited to rely on in-process status for production recovery** (`C-NO-STATEFUL-RECOVERY`): Restart and continuation (deduplication, sinceState, Streams breakpoints, circuit breaker counts, sessions) are all implemented by external Redis + JMAP reconciliation; the in-process cache is only for performance optimization, and the loss must be safe and reentrant.
- Graceful exit: signal + `CancellationToken`, flush Redis Streams pending entries before exiting (**Do not write local files**, `C-NO-LOCAL-WRITE`). - Graceful exit: signal + `CancellationToken`, flush Redis Streams pending entries before exiting (**Do not write local files**, `C-NO-LOCAL-WRITE`).
- **Current status**: Configuration guidance, HTTP entry and write entry authentication completed (`R1`/`SAF-AUTH-*`, fail-closed); JMAP session, Email/changes, PushSubscription create/update, Redis Streams worker and `/reconcile` have been connected. `/reconcile` and the Push closed loop still need to complete end-to-end acceptance in the real Stalwart, Redis, and Telegram environments. You cannot claim that the production link has been verified based on local access control alone. - **Current status**: Configuration guidance, HTTP entry and write entry authentication completed (`R1`/`SAF-AUTH-*`, fail-closed); JMAP session, Email/changes, PushSubscription create/update, Redis Streams worker and `/reconcile` have been connected. `/reconcile` and the Push closed loop still need to complete end-to-end acceptance in the real Stalwart, Redis, and Telegram environments. You cannot claim that the production link has been verified based on local access control alone.
### 5.4 Data flow: new email push (critical path, FLOW-NEW-MAIL) ### 5.4 Data flow: new email push (critical path, FLOW-NEW-MAIL)
``` ```
Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state}) Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_state})
→ 校验 pushSubscriptionId + verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK → 校验 pushSubscriptionId + verificationCode → 幂等去重(MOD-DEDUP) → 入 Redis Streams(MOD-STREAMS) → 立即 2xx ACK
→ worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id → worker: XREADGROUP → 用 sinceState(存 Redis, MOD-SINCESTATE) 调 Email/changes → 取 created[] 的 id
→ read_email 封装 email_get(Subject, Preview, From, TextBody, BodyValues, Size, ReceivedAt, HasAttachment)→ EmailMetadata → read_email 封装 email_get(Subject, Preview, From, TextBody, BodyValues, Size, ReceivedAt, HasAttachment)→ EmailMetadata
→ notify::core 组装领域 Notification(仅元数据 + 行内按钮意图,绝不含正文) → notify::core 组装领域 Notification(仅元数据 + 行内按钮意图,绝不含正文)
→ channel::telegram::TelegramClient::send_notification(chat, notification)(内部渲染文本后经 send_text 发出) → channel::telegram::TelegramClient::send_notification(chat, notification)(内部渲染文本后经 send_text 发出)
→ 更新 sinceState(写外部 Redis)→ XACK → 更新 sinceState(写外部 Redis)→ XACK
→ 若 Redis 丢失:由外部 Cron 对账(FLOW-RECONCILE) 重建游标并补发 → 若 Redis 丢失:由外部 Cron 对账(FLOW-RECONCILE) 重建游标并补发
``` ```
> Key: `Email/changes` lists the new email id in `created` to avoid full `email_query`; `sinceState` is stored in external Redis (`C-REDIS-ONLY-STATE`). When Redis is lost, it will be reconstructed from JMAP by reconciliation, and the **fact source is in JMAP**. > Key: `Email/changes` lists the new email id in `created` to avoid full `email_query`; `sinceState` is stored in external Redis (`C-REDIS-ONLY-STATE`). When Redis is lost, it will be reconstructed from JMAP by reconciliation, and the **fact source is in JMAP**.
> Notifications only contain sender/subject/time (+number of attachments), the text never appears in the notification (see `docs/charter.md §3` Security Boundary `SAF-NOTIFY-META`). > Notifications only contain sender/subject/time (+number of attachments), the text never appears in the notification (see `docs/charter.md §3` Security Boundary `SAF-NOTIFY-META`).
@ -265,36 +265,36 @@ Stalwart JMAP Push → POST /push/jmap (StateChange{Email/EmailDelivery: new_sta
### 5.5 Data flow: command `/read <seq>` (long mail branch) ### 5.5 Data flow: command `/read <seq>` (long mail branch)
``` ```
TG /read 3 → handler 取会话里的 folder+page 游标 TG /read 3 → handler 取会话里的 folder+page 游标
→ JmapService.list_emails(folder, page=3) → JmapService.list_emails(folder, page=3)
→ 取第 3 封 id → read_email(id, body=true) # JMAP 直取,绝不经 LLM → 取第 3 封 id → read_email(id, body=true) # JMAP 直取,绝不经 LLM
→ 格式化: → 格式化:
正文 ≤ long_email_char_limit(默认 4000) → 正常发送(HTML escape) 正文 ≤ long_email_char_limit(默认 4000) → 正常发送(HTML escape)
正文 > 阈值 → 不发全文,发 Preview(120) + 发件人/主题/附件概览 + 选项: 正文 > 阈值 → 不发全文,发 Preview(120) + 发件人/主题/附件概览 + 选项:
"[AI 总结](约300字,需确认) / [继续查看原文(截断,标注‘完整请电脑查看’)]" "[AI 总结](约300字,需确认) / [继续查看原文(截断,标注‘完整请电脑查看’)]"
→ reply → reply
("AI 总结"分支必须用户确认后才把正文交 LLM 生成 ~300 字摘要;见 §12.3) ("AI 总结"分支必须用户确认后才把正文交 LLM 生成 ~300 字摘要;见 §12.3)
``` ```
### 5.6 Session State Machine (Conversation FSM) ### 5.6 Conversation FSM
There is currently no FSM. The command route is parsed into `Intent` by `parse_intent` in `src/worker.rs`; the AI ​​authorization state is a Boolean expiration time in Redis (see the AI ​​authorization state section of `docs/reference.md` for the key and TTL), not a multi-step confirmation state. There is currently **no FSM**. `parse_intent` in `src/worker.rs` parses the command line into an `Intent`; the AI consent state is a single boolean plus an expiry in Redis (key and TTL in the AI consent section of `docs/reference.md`), not a multi-step confirmation state.
Invariants that still hold true: AI digests must first be explicitly authorized by the user, and LLM must not be called without authorization. Once the authorization takes effect, the digest will pull the full text of the email body and send it to LLM (`read_email` requests `TextBody` + `BodyValues` and `fetch_text_body_values(true)`, see `src/domain/jmap/client.rs`), but the body will not be sent back to Telegram** - only the summary will be sent outbound, or it will fall back to the first 300 if it fails. characters (`worker.rs:227` Note: body text never reaches Telegram). The invariant that still holds: an AI summary requires the user's explicit consent first — **without a consent state the LLM is never called**. Once consent is live the summary pulls the full email body and sends it to the LLM (`read_email` requests `TextBody` + `BodyValues` via `fetch_text_body_values(true)`, see `src/domain/jmap/client.rs`), but the body is not sent back to Telegram — outbound sends only the summary, or falls back to the first 300 characters of the body if the LLM call fails (`fallback` in `src/worker.rs`). See also the doc comment on `MetadataWorker`: "body text never reaches Telegram".
The designed 5-state FSM (`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`), together with the state transition table and channel neutrality description, can be found in `docs/retired.md`. The 5-state FSM that was designed (`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`), with its state-transition table and channel-neutrality notes, is in `docs/retired.md`.
### 5.7 Email search (`/search`, `bfe0fd8` implemented) ### 5.7 Email search (`/search`, `bfe0fd8` implemented)
`/search <keyword>` and the Chinese prefix `search`/`find`/`retrieval` (**only prefix matching**, to avoid "Help me search..." being hijacked into a search) triggers `Intent::Search`, through `JmapService::search_emails(account_id, query, limit)` and `email_query`(`Filter::text`) to get the ID + `SearchSnippet/get` Highlight `subject`/`preview`. `/search <keyword>`, plus the Chinese prefixes `搜索` / `查找` / `检索` (**prefix match only**, so "help me search…" is not hijacked into a search), route to `Intent::Search`, which goes through `JmapService::search_emails(account_id, query, limit)` with `email_query` (`Filter::text`) for the IDs and `SearchSnippet/get` for highlighted `subject` / `preview`.
Key boundaries: Key boundaries:
- **Highlight downgrade**: `SearchSnippet/get` does not support (`unknownMethod`) or returns empty snippets instead of an error when it times out; rendering is downgraded to a pure ID list (`highlighted snippets are temporarily unavailable, the following are matching email IDs`), and snippets are not created. - **Snippet degradation**: `SearchSnippet/get` being unsupported (`unknownMethod`) or timing out with empty snippets instead of an error degrades rendering to a plain ID list (`(高亮片段暂不可用,以下为匹配的邮件 ID)`); no snippets are fabricated.
- **Plain text outbound**: `<mark>` highlighted tags are stripped by `strip_mark_tags` (case-insensitive, unclosed tags are discarded), and then decoded in a single pass by `unescape_html_entities`; tags are stripped first and then entities are decoded to prevent the literal `<mark>` (escaped by the server as `<mark>`) in the mailbox body from being restored to a real tag and accidentally deleted. `subject`/`preview` have 120/160 character truncation upper limit respectively (`truncate_chars`, the same tool as text truncation). - **Plain text outbound**: `<mark>` tags are stripped by `strip_mark_tags` (case-insensitive, unclosed tags discarded) and then decoded in one pass by `unescape_html_entities`; tags are stripped **before** entities are decoded so that a literal `<mark>` in the mailbox body (escaped by the server as `<mark>`) is not restored to a real tag and then accidentally deleted. `subject` and `preview` are capped at 120 and 160 characters (`truncate_chars`, the same helper as text truncation).
- **Failed mapping**: JMAP side failed → `SearchReply::Retry` → `process_telegram` returns `Err(())` → coordinator 503 + `Retry-After` retry (consistent with `/reconcile`, other JMAP paths), **no panic, no error stack returned to user**. - **Failed mapping**: JMAP side failed → `SearchReply::Retry` → `process_telegram` returns `Err(())` → coordinator 503 + `Retry-After` retry (consistent with `/reconcile`, other JMAP paths), **no panic, no error stack returned to user**.
- **Empty query**: `/search` (no keywords) → The guidance prompt "Please provide search keywords, for example: /search invoice", no request is sent. No match → `No messages matching "..." were found. `. - **Empty query**: `/search` with no keywords emits a guidance prompt (`请提供搜索关键词,例如:/search 发票`) and makes no request. No matches returns `没有找到匹配「<query>」的邮件。`.
- **Each hit contains `email_id`**, you can directly access `/summary <email_id>` to enter the AI ​​summary process. - **Each hit carries `email_id`**, so `/summary <email_id>` jumps straight into the AI summary flow.
- **Body-level highlighting is not possible in locked versions**: jmap-client `0.4.2`’s `SearchSnippet` only models `emailId`/`subject`/`preview`, without `bodyProperties`/`parts`. According to RFC 8621 §5, the body-level `body: String[Id]` is not modeled by this crate and (none) `deny_unknown_fields`) are silently discarded by serde. The text highlighting of each part needs to bypass the crate and be sent directly to the original JMAP, which is not currently implemented. - **Body-level highlighting is not possible in locked versions**: jmap-client `0.4.2`’s `SearchSnippet` only models `emailId`/`subject`/`preview`, without `bodyProperties`/`parts`. Per RFC 8621 §5 the body-level `body: String[Id]` is not modeled by the crate and, with no `deny_unknown_fields`, is silently dropped by serde. Per-part body highlighting would require bypassing the crate and calling JMAP directly, which is not implemented.
- **No new environment variable**; `/search` will give a friendly error when the configuration is missing. - **No new environment variable**; when the configuration is missing, `/search` reports a friendly error.
--- ---
@ -303,30 +303,30 @@ Key boundaries:
Actual project structure (all code anchors in this document refer to this structure; see `docs/retired.md` for the historical target module splitting plan): Actual project structure (all code anchors in this document refer to this structure; see `docs/retired.md` for the historical target module splitting plan):
``` ```
message-weave/ 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,边界见 deployment.md §3)
├── 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 子命令)
│ ├── config.rs # Redis 业务配置反序列化(serde → BusinessConfigWire → Config);CONFIG_ENCRYPTION_KEY 解析 │ ├── config.rs # Redis 业务配置反序列化(serde → BusinessConfigWire → Config);CONFIG_ENCRYPTION_KEY 解析
│ ├── error.rs # BotError 统一错误(见 §8.1) │ ├── error.rs # BotError 统一错误(见 §8.1)
│ ├── domain.rs # 领域层根(渠道中立,见 §5.2) │ ├── domain.rs # 领域层根(渠道中立,见 §5.2)
│ ├── domain/jmap.rs # JmapBackend trait + MockBackend(契约测试不联网) │ ├── domain/jmap.rs # JmapBackend trait + MockBackend(契约测试不联网)
│ ├── domain/jmap/client.rs # 包装 jmap_client::Client(真实只读 adapter,MOD-JMAP-CLIENT) │ ├── domain/jmap/client.rs # 包装 jmap_client::Client(真实只读 adapter,MOD-JMAP-CLIENT)
│ ├── state.rs # Redis 读写封装:配置/开关/会话/TTL 键(C-REDIS-ONLY-STATE) │ ├── state.rs # Redis 读写封装:配置/开关/会话/TTL 键(C-REDIS-ONLY-STATE)
│ ├── channel.rs # 渠道层:TelegramClient(reqwest 自研出站);领域类型在 src/domain.rs │ ├── channel.rs # 渠道层:TelegramClient(reqwest 自研出站);领域类型在 src/domain.rs
│ ├── notify.rs # /push/jmap 校验入队 + Redis Streams worker + /reconcile 对账 │ ├── notify.rs # /push/jmap 校验入队 + Redis Streams worker + /reconcile 对账
│ ├── worker.rs # parse_intent → Intent,命令路由与 AI 授权判定 │ ├── worker.rs # parse_intent → Intent,命令路由与 AI 授权判定
│ ├── ai.rs # LlmClient / summarize:LLM 摘要调用 │ ├── ai.rs # LlmClient / summarize:LLM 摘要调用
│ └── web.rs # /config 静态前端路由 + include_str! 嵌入 + CSP │ └── web.rs # /config 静态前端路由 + include_str! 嵌入 + CSP
├── web/ # 前端资源,构建期 include_str! 嵌入二进制(C-NO-LOCAL-WRITE) ├── web/ # 前端资源,构建期 include_str! 嵌入二进制(C-NO-LOCAL-WRITE)
│ ├── index.html │ ├── index.html
│ ├── styles.css │ ├── styles.css
│ ├── config.js │ ├── config.js
│ └── config.test.mjs # Node 原生断言测试(非 cargo 测试) │ └── config.test.mjs # Node 原生断言测试(非 cargo 测试)
└── cloudflare-worker/ # 边缘反向代理,JS,不在 Rust 工程中 └── cloudflare-worker/ # 边缘反向代理,JS,不在 Rust 工程中
``` ```
Testing: **No integrated test directory**, and no stand-alone util tool module. All Rust tests are `#[cfg(test)]` unit tests within each module; the only test on the front end is `web/config.test.mjs`. Testing: **No integrated test directory**, and no stand-alone util tool module. All Rust tests are `#[cfg(test)]` unit tests within each module; the only test on the front end is `web/config.test.mjs`.
@ -407,16 +407,16 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
### 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**——
- `/reconcile`: Request header `Authorization: Bearer <RECONCILE_TOKEN>`; - `/reconcile`: Request header `Authorization: Bearer <RECONCILE_TOKEN>`;
- `/webhook/tg`: Request header `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`; - `/webhook/tg`: Request header `X-Telegram-Bot-Api-Secret-Token == TG_WEBHOOK_SECRET`;
- `/push/jmap`: Verify Redis short-term status according to `pushSubscriptionId`; StateChange is released only after the first verification is successfully written back by JMAP `PushSubscription/set`. - `/push/jmap`: Verify Redis short-term status according to `pushSubscriptionId`; StateChange is released only after the first verification is successfully written back by JMAP `PushSubscription/set`.
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 deployment.md).
- **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.
### 7.3.1 Configuration Management API ### 7.3.1 Configuration Management API
- `GET /` provides a SPA embedded in the Rust binary; `/assets/config.js` and `/assets/styles.css` provide page resources. The service does not read or write local files while running (`C-NO-LOCAL-WRITE`). - `GET /` provides a SPA embedded in the Rust binary; `/assets/config.js` and `/assets/styles.css` provide page resources. The service does not read or write local files while running (`C-NO-LOCAL-WRITE`).
@ -424,15 +424,15 @@ Testing: **No integrated test directory**, and no stand-alone util tool module.
- `POST /api/admin/session` accepts `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>` and returns `{ "session": "<opaque>", "expires_in": 1800 }`; the admin session only saves the digest in Redis and expires after 1,800 seconds. `POST /api/admin/session/revoke` revokes the current session and returns `204` successfully. - `POST /api/admin/session` accepts `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>` and returns `{ "session": "<opaque>", "expires_in": 1800 }`; the admin session only saves the digest in Redis and expires after 1,800 seconds. `POST /api/admin/session/revoke` revokes the current session and returns `204` successfully.
- The admin page only saves the session in JavaScript memory. Request settings `credentials: omit`, `cache: no-store`, without using cookies, localStorage or sessionStorage. The admin API accepts a valid admin session; the compatibility path also accepts `WORKER_TOKEN`. Worker transparently transmits the authentication header (`SAF-LB-PASSTHRU`) as it is. - The admin page only saves the session in JavaScript memory. Request settings `credentials: omit`, `cache: no-store`, without using cookies, localStorage or sessionStorage. The admin API accepts a valid admin session; the compatibility path also accepts `WORKER_TOKEN`. Worker transparently transmits the authentication header (`SAF-LB-PASSTHRU`) as it is.
- `GET /api/config` and `PUT /api/config` only read and write non-sensitive running parameters: - `GET /api/config` and `PUT /api/config` only read and write non-sensitive running parameters:
```json ```json
{ {
"jmap_timeout_ms": 15000, "jmap_timeout_ms": 15000,
"telegram_timeout_ms": 10000, "telegram_timeout_ms": 10000,
"llm_timeout_ms": 30000, "llm_timeout_ms": 30000,
"max_retries": 3 "max_retries": 3
} }
``` ```
- The three timeout units are all milliseconds; the default value, value range and hard upper limit of `max_retries` are only authoritative with `docs/reference.md` §6.1 (the table below and the example response body above are only for illustration, and the values ​​​​are not repeated). `max_retries` represents the maximum number of retries beyond the first request. When there is no configuration in Redis, GET returns the default value object; PUT successfully returns the same object after saving and persists to Redis (`C-REDIS-ONLY-STATE`). - The three timeout units are all milliseconds; the default value, value range and hard upper limit of `max_retries` are only authoritative with `docs/reference.md` §6.1 (the table below and the example response body above are only for illustration, and the values are not repeated). `max_retries` represents the maximum number of retries beyond the first request. When there is no configuration in Redis, GET returns the default value object; PUT successfully returns the same object after saving and persists to Redis (`C-REDIS-ONLY-STATE`).
- `GET /api/business-config` reads back saved configuration for management SPA pre-populated form: returns `{ "configured", "revision", "values", "secrets_present" }`, where `values` is the value of 10 non-key fields and `secrets_present` is 6 Boolean existence of a key (`bot_token`, `jmap_password`, `telegram_webhook_secret`, `reconcile_token`, `worker_token`, `llm_api_key`) - **Never return the key value itself** (`SAF-NO-SECRET-ECHO`). Not yet saved, staleness returns `200` + `configured: false`, `revision: 0`, empty `values` and an existence boolean of all false. SPA therefore has only one code path and does not need to distinguish between first-time configuration and modified configuration. - `GET /api/business-config` reads back saved configuration for management SPA pre-populated form: returns `{ "configured", "revision", "values", "secrets_present" }`, where `values` is the value of 10 non-key fields and `secrets_present` is 6 Boolean existence of a key (`bot_token`, `jmap_password`, `telegram_webhook_secret`, `reconcile_token`, `worker_token`, `llm_api_key`) - **Never return the key value itself** (`SAF-NO-SECRET-ECHO`). Not yet saved, staleness returns `200` + `configured: false`, `revision: 0`, empty `values` and an existence boolean of all false. SPA therefore has only one code path and does not need to distinguish between first-time configuration and modified configuration.
- `PUT /api/business-config` accepts `BusinessConfigPatch` (complete or partial), **only replaces the fields that appear in the submission body, and the rest inherits the stored values** (`BusinessConfigPatch::apply`); Push verification does not belong to Wire, is dynamically generated by Stalwart and written back by the backend. Verification is the only write gate, and the verification object is the merged configuration rather than the commit itself: patches are never verified individually, and existing complete wires are never discarded. `validate_business_wire` runs first, and returns the real `422` when rejected; if the merge result is legal, the configuration **must** be persisted (the merged Wire is persisted, not the request body), and then the client is built and the worker is switched to run. Therefore, when the dependency is not connected, `200` + `{ "persisted": true, "runtime_applied": false, "warnings": [{"component", "step", "detail"}] }` is returned instead of `503` - the configuration has been saved and the fault is reported truthfully instead of being silently swallowed by an unreachable JMAP. `runtime_applied` is only `true` when the reload submission is successful; in both cases, the `x-business-config-revision` header is returned; the request body can carry `revision` (that is, the `u64` returned by `GET`) as a control field - it does not participate in `apply`, does not enter Wire, and is not used as a saved setting. It is only used to determine whether this submission is based on the latest version. See the next article for the semantics of `409`. The response never echoes configuration or keys. The key is "replaced only after submission": the **omitted** key inherits the existing value, so the form backfilled by `GET` only needs to submit the fields to be changed; but the backend does not distinguish between "unsubmitted" and "submitted empty string", and explicitly submitting `""` will actually clear the key - "leave blank and keep" is a client contract implemented by SPA by discarding blank key fields before submission, not a server-side guarantee. `chat_allowlist` must be a non-empty list, empty arrays will be rejected by `validate_business_wire` (422). Incremental semantics only hold after **existing configuration**: if there is no existing configuration, there is no fallback value, and the patch must come with all required fields, otherwise `422` will be returned (the entry created from scratch is `POST /api/bootstrap`). - `PUT /api/business-config` accepts `BusinessConfigPatch` (complete or partial), **only replaces the fields that appear in the submission body, and the rest inherits the stored values** (`BusinessConfigPatch::apply`); Push verification does not belong to Wire, is dynamically generated by Stalwart and written back by the backend. Verification is the only write gate, and the verification object is the merged configuration rather than the commit itself: patches are never verified individually, and existing complete wires are never discarded. `validate_business_wire` runs first, and returns the real `422` when rejected; if the merge result is legal, the configuration **must** be persisted (the merged Wire is persisted, not the request body), and then the client is built and the worker is switched to run. Therefore, when the dependency is not connected, `200` + `{ "persisted": true, "runtime_applied": false, "warnings": [{"component", "step", "detail"}] }` is returned instead of `503` - the configuration has been saved and the fault is reported truthfully instead of being silently swallowed by an unreachable JMAP. `runtime_applied` is only `true` when the reload submission is successful; in both cases, the `x-business-config-revision` header is returned; the request body can carry `revision` (that is, the `u64` returned by `GET`) as a control field - it does not participate in `apply`, does not enter Wire, and is not used as a saved setting. It is only used to determine whether this submission is based on the latest version. See the next article for the semantics of `409`. The response never echoes configuration or keys. The key is "replaced only after submission": the **omitted** key inherits the existing value, so the form backfilled by `GET` only needs to submit the fields to be changed; but the backend does not distinguish between "unsubmitted" and "submitted empty string", and explicitly submitting `""` will actually clear the key - "leave blank and keep" is a client contract implemented by SPA by discarding blank key fields before submission, not a server-side guarantee. `chat_allowlist` must be a non-empty list, empty arrays will be rejected by `validate_business_wire` (422). Incremental semantics only hold after **existing configuration**: if there is no existing configuration, there is no fallback value, and the patch must come with all required fields, otherwise `422` will be returned (the entry created from scratch is `POST /api/bootstrap`).
- `POST /api/business-config/preflight` performs exactly the same validation and client build on the submission body, returning component-by-component conclusions, **without writing, without touching the running worker**: `{ "persisted": false, "validation": { "ok", "errors" }, "components": { "jmap": {...}, "llm": ... } }`. Different from PUT, its submission body must be **complete** `BusinessConfigWire` (no patch merging), so SPA must first merge the value read back by `GET` into the local form before pre-verification. `components` is `null` when the verification has failed (rejected Wire does not need to probe the client again); when `llm_enabled`/`llm_allow_net` is false, `llm` is `null` instead of false `ok`. Authentication is consistent with the management session gate (`config_authorized`) and is deliberately not hung after `DEBUG_TOKEN`. - `POST /api/business-config/preflight` performs exactly the same validation and client build on the submission body, returning component-by-component conclusions, **without writing, without touching the running worker**: `{ "persisted": false, "validation": { "ok", "errors" }, "components": { "jmap": {...}, "llm": ... } }`. Different from PUT, its submission body must be **complete** `BusinessConfigWire` (no patch merging), so SPA must first merge the value read back by `GET` into the local form before pre-verification. `components` is `null` when the verification has failed (rejected Wire does not need to probe the client again); when `llm_enabled`/`llm_allow_net` is false, `llm` is `null` instead of false `ok`. Authentication is consistent with the management session gate (`config_authorized`) and is deliberately not hung after `DEBUG_TOKEN`.
@ -475,14 +475,14 @@ use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum BotError { pub enum BotError {
#[error("configuration error: {0}")] #[error("configuration error: {0}")]
Config(String), Config(String),
#[error("I/O error: {0}")] #[error("I/O error: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("JSON error: {0}")] #[error("JSON error: {0}")]
Json(#[from] serde_json::Error), Json(#[from] serde_json::Error),
#[error("state error")] #[error("state error")]
State(#[from] crate::state::StateError), State(#[from] crate::state::StateError),
} }
``` ```
@ -531,17 +531,17 @@ The design target form once included six additional variants: `Jmap(#[from] jmap
- Notification deduplication: the same `email_id` will not be pushed repeatedly. - Notification deduplication: the same `email_id` will not be pushed repeatedly.
- Text escaping: Message text containing `<script>` is escaped in HTML mode. - Text escaping: Message text containing `<script>` is escaped in HTML mode.
- **Security boundary assertion (hard, see `docs/charter.md §3`)**: - **Security boundary assertion (hard, see `docs/charter.md §3`)**:
- VIEW/View original text path: mock AI endpoint zero request (LLM client not called); - VIEW/View original text path: mock AI endpoint zero request (LLM client not called);
- New email notification: There is no body content in the message (assertion leak); - New email notification: There is no body content in the message (assertion leak);
- Long emails: if the text is > 4000 characters, the full text will not be sent; - Long emails: if the text is > 4000 characters, the full text will not be sent;
- AI call prefix: LLM zero call before confirmation; - AI call prefix: LLM zero call before confirmation;
- The analysis results are not saved to disk: no new disk/Redis writing path is added after processing; - The analysis results are not saved to disk: no new disk/Redis writing path is added after processing;
- AI fails 3 times → circuit breaker confirmation → fallback with "AI unavailable" logo. - AI fails 3 times → circuit breaker confirmation → fallback with "AI unavailable" logo.
- **Entry Authentication (`SAF-AUTH-*`)**: `/reconcile`, `/webhook/tg`, `/push/jmap` returns `401` under **missing or wrong** credentials, and asserts **no side effects** (no Redis writes, no JMAP/AI calls) when authentication fails; correct credentials are allowed. - **Entry Authentication (`SAF-AUTH-*`)**: `/reconcile`, `/webhook/tg`, `/push/jmap` returns `401` under **missing or wrong** credentials, and asserts **no side effects** (no Redis writes, no JMAP/AI calls) when authentication fails; correct credentials are allowed.
- **Health Probe (`SAF-PROBE-PUBLIC`)**: `/healthz` returns `200` for process alive; `/ready` returns `200/503` for readiness - checks configuration integrity + Redis reachability + outbound read-only probe (`GET {jmap_origin}/.well-known/jmap` with Basic authentication, `GET https://api.telegram.org/bot<token>/getMe`, 3s each, parallel, about 3s at worst), any failure will return the standard error envelope (`service_unavailable` + `Retry-After: 30`), all will return the readiness report JSON; the response body does not contain sensitive information. Uptime Kuma is monitored by status code (`/ready` expects 200) and is not affected by changes in the response body. - **Health Probe (`SAF-PROBE-PUBLIC`)**: `/healthz` returns `200` for process alive; `/ready` returns `200/503` for readiness - checks configuration integrity + Redis reachability + outbound read-only probe (`GET {jmap_origin}/.well-known/jmap` with Basic authentication, `GET https://api.telegram.org/bot<token>/getMe`, 3s each, parallel, about 3s at worst), any failure will return the standard error envelope (`service_unavailable` + `Retry-After: 30`), all will return the readiness report JSON; the response body does not contain sensitive information. Uptime Kuma is monitored by status code (`/ready` expects 200) and is not affected by changes in the response body.
- **Channel decoupling**: No channel SDK types appear in the public interfaces of domain modules (`src/domain.rs` / `src/ai.rs` / `src/worker.rs`); channel assembly is centralized in `src/channel.rs`. - **Channel decoupling**: No channel SDK types appear in the public interfaces of domain modules (`src/domain.rs` / `src/ai.rs` / `src/worker.rs`); channel assembly is centralized in `src/channel.rs`.
- **JMAP session URL (`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**: both the base address and the complete `…/.well-known/jmap` are accepted and the normalized results are consistent**, the URL passed to `Client::connect` does not contain duplicate `/.well-known/jmap`**; `http://` Rejected; **Embedded credentials (`https://user:pass@host`) are rejected**; Dangerous queries are rejected; Redirects only trust the origin host in the configuration (Stalwart will 307 jump to `/jmap/session`). - **JMAP session URL (`REQ-JMAP-SESSION-URL`/`SAF-JMAP-URL`)**: both the base address and the complete `…/.well-known/jmap` are accepted and the normalized results are consistent**, the URL passed to `Client::connect` does not contain duplicate `/.well-known/jmap`**; `http://` Rejected; **Embedded credentials (`https://user:pass@host`) are rejected**; Dangerous queries are rejected; Redirects only trust the origin host in the configuration (Stalwart will 307 jump to `/jmap/session`).
- **JMAP multi-part original text (`REQ-JMAP-RAW-MULTIPART`)**: `read_email` splices the parts with `part_id` and `bodyValue`" for the multi-part text in the order of `text_body`; constructing the "no available part" use case assertion returns **clear error** (non-empty string). - **JMAP multi-part original text (`REQ-JMAP-RAW-MULTIPART`)**: `read_email` splices the parts with `part_id` and `bodyValue`" for the multi-part text in the order of `text_body`; constructing the "no available part" use case assertion returns **clear error** (non-empty string).
--- ---
@ -556,7 +556,7 @@ The design target form once included six additional variants: `Jmap(#[from] jmap
- **Actual dependencies** (Cargo.toml current, `ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1` + `ARCH-DEPS-STAGE4`): `axum 0.8` (single HTTPS entrance), `async-trait`, `secrecy`, `subtle` (constant time authentication comparison), `serde`, `serde_json`, `thiserror`, `tokio`, `tracing`, `tracing-subscriber`, `url` (`JMAP_SESSION_URL` normalized parsing), `jmap-client =0.4.2` (`default-features = false, features = ["async","rustls"]`), `redis 0.27` (Redis XPING/PING liveness detection, `ARCH-STATE-REDIS`), `reqwest 0.13` (JMAP/TG HTTP client); dev-dependencies: `tower 0.5` (routing test). `teloxide` is not introduced (`ARCH-DEPS-STAGE4`): Telegram channel is implemented in `src/channel.rs` using reqwest self-development. - **Actual dependencies** (Cargo.toml current, `ARCH-DEPS-STAGE0`/`ARCH-DEPS-STAGE1` + `ARCH-DEPS-STAGE4`): `axum 0.8` (single HTTPS entrance), `async-trait`, `secrecy`, `subtle` (constant time authentication comparison), `serde`, `serde_json`, `thiserror`, `tokio`, `tracing`, `tracing-subscriber`, `url` (`JMAP_SESSION_URL` normalized parsing), `jmap-client =0.4.2` (`default-features = false, features = ["async","rustls"]`), `redis 0.27` (Redis XPING/PING liveness detection, `ARCH-STATE-REDIS`), `reqwest 0.13` (JMAP/TG HTTP client); dev-dependencies: `tower 0.5` (routing test). `teloxide` is not introduced (`ARCH-DEPS-STAGE4`): Telegram channel is implemented in `src/channel.rs` using reqwest self-development.
- **Not yet introduced** (Documents must not claim to have been used): `teloxide` and other Telegram Bot frameworks (Telegram outbound is sent directly by `src/channel.rs` and `reqwest`, and the evaluation record is in `docs/retired.md`). **FIGMENT NOT USED**: Configured for manual `std::env` parsing (`ARCH-CONFIG-ENV`, §7.1). - **Not yet introduced** (Documents must not claim to have been used): `teloxide` and other Telegram Bot frameworks (Telegram outbound is sent directly by `src/channel.rs` and `reqwest`, and the evaluation record is in `docs/retired.md`). **FIGMENT NOT USED**: Configured for manual `std::env` parsing (`ARCH-CONFIG-ENV`, §7.1).
- **Stage 1 dependency status** (`ARCH-DEPS-STAGE1`): `jmap-client` **Introduced** (currently `=0.4.2`, `default-features = false, features = ["async","rustls"]`, version and features **subject to `Cargo.toml`**). ⚠️ Its default features `["async","websockets","aws_lc_rs"]` **includes WebSocket stack**, so the default features must be turned off and `websockets` is not selected; JMAP only uses **HTTPS short requests** (Core/Mail) to comply with `C-NO-LONG-CONN` (no WS/SSE/long polling). - **Stage 1 dependency status** (`ARCH-DEPS-STAGE1`): `jmap-client` **Introduced** (currently `=0.4.2`, `default-features = false, features = ["async","rustls"]`, version and features **subject to `Cargo.toml`**). ⚠️ Its default features `["async","websockets","aws_lc_rs"]` **includes WebSocket stack**, so the default features must be turned off and `websockets` is not selected; JMAP only uses **HTTPS short requests** (Core/Mail) to comply with `C-NO-LONG-CONN` (no WS/SSE/long polling).
- `ACCOUNT_ID` **optional** (`REQ-SINGLE-ACCOUNT`): **leave blank → take the default/main account** of the JMAP session; explicit values ​​are used after verification; multiple accounts = multiple bot instances. - `ACCOUNT_ID` **optional** (`REQ-SINGLE-ACCOUNT`): **leave blank → take the default/main account** of the JMAP session; explicit values are used after verification; multiple accounts = multiple bot instances.
- axum adopts **0.8** (`ARCH-AXUM-08`); if subsequent review decides to adjust the version, Cargo.toml will prevail and this section will be synchronized. - axum adopts **0.8** (`ARCH-AXUM-08`); if subsequent review decides to adjust the version, Cargo.toml will prevail and this section will be synchronized.
- Currently SSE/WebSocket/long polling/SQLite/local volumes (`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`) are not implemented; entrance authentication (`R1`/`SAF-AUTH-*`) is implemented as a fail-closed hard access control, and the Push, Streams worker and `/reconcile` business paths after authentication have been implemented. - Currently SSE/WebSocket/long polling/SQLite/local volumes (`C-NO-LONG-CONN`/`NG-SQLITE-PERSIST`/`NG-LOCAL-VOLUME`) are not implemented; entrance authentication (`R1`/`SAF-AUTH-*`) is implemented as a fail-closed hard access control, and the Push, Streams worker and `/reconcile` business paths after authentication have been implemented.
@ -643,9 +643,9 @@ Both are wrong, and the real 360s solo lock `lock:push-register:{sha256(callback
Single authority table, this section only retains four "whys" that affect design judgment: Single authority table, this section only retains four "whys" that affect design judgment:
- **Reconciliation lock TTL 300s, deliberately larger than the upper limit of 120s for a single page**, to avoid lock expiration during the lock period, causing the same account to enter reconciliation repeatedly - **Reconciliation lock TTL 300s, deliberately larger than the upper limit of 120s for a single page**, to avoid lock expiration during the lock period, causing the same account to enter reconciliation repeatedly
(`REQ-RECONCILE-IDEMPOTENCY`, `SAF-RECONCILE-LOCK`). (`REQ-RECONCILE-IDEMPOTENCY`, `SAF-RECONCILE-LOCK`).
- **push registration solo lock TTL 360s, deliberately larger than the upper limit of 300s for a single outbound request**, otherwise a slow registration will cause repeated requests - **push registration solo lock TTL 360s, deliberately larger than the upper limit of 300s for a single outbound request**, otherwise a slow registration will cause repeated requests
(`SAF-AUTH-JMAP-PUSH`). (`SAF-AUTH-JMAP-PUSH`).
- **Reconciliation deduplication 24h is intentionally designed**: the same email will only be notified once within 24h (`MOD-DEDUP`, see 11.5 of this article for the scope). - **Reconciliation deduplication 24h is intentionally designed**: the same email will only be notified once within 24h (`MOD-DEDUP`, see 11.5 of this article for the scope).
- **If the configuration key is missing, it will be regarded as failed (fail-closed)** and the business path will not be entered. - **If the configuration key is missing, it will be regarded as failed (fail-closed)** and the business path will not be entered.
@ -728,7 +728,7 @@ There are no pending decisions regarding deployment/platform decisions: `Q-DEP-A
### 12.1 Module division (current implementation) ### 12.1 Module division (current implementation)
There is only **one file** for LLM related code: There is only **one file** for LLM related code:
``` ```
src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/completions src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/completions
``` ```
The configuration is loaded in `LlmConfig` in `src/config.rs`, and the runtime parameters are in `OutboundConfig` in `src/state.rs` (delivered by `RuntimeConfigProvider`). The configuration is loaded in `LlmConfig` in `src/config.rs`, and the runtime parameters are in `OutboundConfig` in `src/state.rs` (delivered by `RuntimeConfigProvider`).
The LLM capability only exists in the `src/ai.rs` file; **no** configuration/fallback/policy/audit layering - circuit breaker, rule fallback, and audit span are all not implemented (see §12.4–12.7). The LLM capability only exists in the `src/ai.rs` file; **no** configuration/fallback/policy/audit layering - circuit breaker, rule fallback, and audit span are all not implemented (see §12.4–12.7).

View File

@ -38,7 +38,7 @@ Original entry: Full code base **12 TTL write points, 17 TTL values**, but only
Three steps closed the issue: Three steps closed the issue:
1. **True Redis Assertion** (commit `0890eb1`). `real_redis_ttl_tests` of `src/state.rs`, two `#[ignore]`-gated use cases, follow the pattern of `src/domain/jmap/client.rs:522`: skipped and returned when `REDIS_TEST_URL` is missing, the URL will not be logged. Assert actual PTTL for three atomic write points. Run: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl`. 1. **True Redis Assertion** (commit `0890eb1`). `real_redis_ttl_tests` of `src/state.rs`, two `#[ignore]`-gated use cases, follow the pattern of `src/domain/jmap/client.rs:522`: skipped and returned when `REDIS_TEST_URL` is missing, the URL will not be logged. Assert actual PTTL for three atomic write points. Run: `REDIS_TEST_URL=… cargo test --locked -- --ignored ttl`.
2. **Kill naked literals** (one round after commit `0890eb1`). All 17 values ​​​​are included in `pub(crate) mod ttl` of `src/state.rs`, the call point changes the constant reference, and a new `ttl_contract_is_pinned` is added to assert the constant table one by one, with two sequential assertions (the heartbeat must be shorter than the lock, and the consent gear must be strictly increasing). Class B Lua embedded values ​​that were out of reach in the original entry have also been connected - `retry_or_dlq` now uses `ttl::RETRY_COUNTER_SECONDS` interpolation to generate script strings, and there is no longer a second literal in the script. Changing the value now = changing the constant + assertion failure, both of which must be reviewed. 2. **Kill naked literals** (one round after commit `0890eb1`). All 17 values are included in `pub(crate) mod ttl` of `src/state.rs`, the call point changes the constant reference, and a new `ttl_contract_is_pinned` is added to assert the constant table one by one, with two sequential assertions (the heartbeat must be shorter than the lock, and the consent gear must be strictly increasing). Class B Lua embedded values that were out of reach in the original entry have also been connected - `retry_or_dlq` now uses `ttl::RETRY_COUNTER_SECONDS` interpolation to generate script strings, and there is no longer a second literal in the script. Changing the value now = changing the constant + assertion failure, both of which must be reviewed.
3. **Real machine green light** (this round, run with Upstash instance). The two assertions were actually connected to the real Redis to read back the PTTL and passed. The Class B Lua embedded value was verified on a real machine for the first time. The process is shown below in "Testing the four TTL test fixes". 3. **Real machine green light** (this round, run with Upstash instance). The two assertions were actually connected to the real Redis to read back the PTTL and passed. The Class B Lua embedded value was verified on a real machine for the first time. The process is shown below in "Testing the four TTL test fixes".
Contract baseline (see `docs/reference.md` §1 for the complete semantics of keys): Contract baseline (see `docs/reference.md` §1 for the complete semantics of keys):
@ -67,7 +67,7 @@ Contract baseline (see `docs/reference.md` §1 for the complete semantics of key
| `ttl::CONSENT_MAXIMUM_SECONDS` | 31_536_000 | `consent:ai:{chat_id}` ("until revoked / long term") | | `ttl::CONSENT_MAXIMUM_SECONDS` | 31_536_000 | `consent:ai:{chat_id}` ("until revoked / long term") |
| `ttl::CONSENT_REVOKED_SECONDS` | 0 | `consent:ai:{chat_id}` (revoked; `max(1)` = 1s, semantics is "expired") | | `ttl::CONSENT_REVOKED_SECONDS` | 0 | `consent:ai:{chat_id}` (revoked; `max(1)` = 1s, semantics is "expired") |
Naked TTL literals no longer exist in production code; `worker.rs`'s agreed-upon test assertions still retain numeric literals, which is **deliberate** - use expected values ​​independent of constants to test production code, otherwise if both sides are changed at the same time, the single test will always be green. Naked TTL literals no longer exist in production code; `worker.rs`'s agreed-upon test assertions still retain numeric literals, which is **deliberate** - use expected values independent of constants to test production code, otherwise if both sides are changed at the same time, the single test will always be green.
This update fixed four bugs in the test itself. This update fixed four bugs in the test itself.
@ -79,7 +79,7 @@ The fourth is that the real machine asserts that it is unstable. The first three
- The zero value case originally asserted `assert_within(pttl, 1, 500)` - requiring the 1 second floor to be read back within ±500 ms. But the readback occurs after the cross-network TLS round trip, and that second is usually consumed. Running three times in a row got `-2` (key has expired), `269`, `118` ms. There is nothing wrong with the product code itself: `SET … NX EX 1` is indeed implemented, and `-2` just proves that EX takes effect and the keys expire as expected. Change the criterion to "`pttl != -1`" - `-1` is the real regression to prevent (EX is silently discarded, the key never expires), plus "`pttl == -2 || pttl <= 1000 + 2000`" is a judgment that only sets an upper bound, because a value that is still alive will only count down from 1s. After the change, I ran 6 consecutive rounds with all green. - The zero value case originally asserted `assert_within(pttl, 1, 500)` - requiring the 1 second floor to be read back within ±500 ms. But the readback occurs after the cross-network TLS round trip, and that second is usually consumed. Running three times in a row got `-2` (key has expired), `269`, `118` ms. There is nothing wrong with the product code itself: `SET … NX EX 1` is indeed implemented, and `-2` just proves that EX takes effect and the keys expire as expected. Change the criterion to "`pttl != -1`" - `-1` is the real regression to prevent (EX is silently discarded, the key never expires), plus "`pttl == -2 || pttl <= 1000 + 2000`" is a judgment that only sets an upper bound, because a value that is still alive will only count down from 1s. After the change, I ran 6 consecutive rounds with all green.
**Two TTL assertions have been run on the real Upstash instance (`rediss://`, TLS-only; plaintext `redis://` will receive a null reply after `AUTH` and disconnect, so TLS must be used)**: `SET … NX EX` (`claim_dedup`), `SET … EX 3600` (`set_ai_consent`), Lua embedded `EXPIRE 86400` (`retry_or_dlq`) The actual PTTL of the three atomic write points all fall within the 2s tolerance. Class B Lua embedded values ​​that were out of reach in the original article are now also verified on real machines, not just constant references. **Two TTL assertions have been run on the real Upstash instance (`rediss://`, TLS-only; plaintext `redis://` will receive a null reply after `AUTH` and disconnect, so TLS must be used)**: `SET … NX EX` (`claim_dedup`), `SET … EX 3600` (`set_ai_consent`), Lua embedded `EXPIRE 86400` (`retry_or_dlq`) The actual PTTL of the three atomic write points all fall within the 2s tolerance. Class B Lua embedded values that were out of reach in the original article are now also verified on real machines, not just constant references.
### 2. `newState` semantics of `Email/changes` (commit `269c8f6`) ### 2. `newState` semantics of `Email/changes` (commit `269c8f6`)

View File

@ -296,7 +296,7 @@ Response conventions:
## 4. Gateway vs backend route matrix ## 4. Gateway vs backend route matrix
> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`, > **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
> 18 entries, alongside `ROUTE_METHODS` (`index.js`) > 19 entries, alongside `ROUTE_METHODS` (`index.js`)
> which fixes one method set per path. The worker entry point is `src/index.js` > which fixes one method set per path. The worker entry point is `src/index.js`
> (`wrangler.toml`); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`, > (`wrangler.toml`); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`,
> `C-NO-LONG-CONN`). > `C-NO-LONG-CONN`).
@ -324,9 +324,12 @@ backend pool returns **503** rather than passing the request through.
`GET, PUT /api/enabled` (the `SAF-ENABLE-FLAG` kill switch) **is** forwarded, because the `GET, PUT /api/enabled` (the `SAF-ENABLE-FLAG` kill switch) **is** forwarded, because the
admin SPA serves it at the Worker URL and toggles it from the service card (`loadEnabled` admin SPA serves it at the Worker URL and toggles it from the service card (`loadEnabled`
reads it, the toggle writes it); both calls are reads it, the toggle writes it). Both endpoints already require credentials, but not the
admin-session Bearer-auth'd, so the exposure is identical to the already-forwarded same ones: `/api/enabled` is gated by `config_authorized`, which accepts either the shared
`/api/admin/session` pair. worker token or a valid admin session, while `POST /api/admin/session` is gated by
`worker_authorized` against the admin credential (`CONFIG_ENCRYPTION_KEY`) and is itself
what mints the session. Adding `/api/enabled` therefore opens no new credential path —
`/api/admin/session` was already in the forwarded set.
Consequence: neither remaining route carries external business traffic, so no second ingress is Consequence: neither remaining route carries external business traffic, so no second ingress is
needed in front of the backend instances. The SPA's first-boot flow still cannot drive needed in front of the backend instances. The SPA's first-boot flow still cannot drive

View File

@ -292,7 +292,7 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读
## 4. 网关与后端路由对照表 ## 4. 网关与后端路由对照表
> **独立验证。** 来源:`cloudflare-worker/src/backends.js` `SAFE_ROUTES`, > **独立验证。** 来源:`cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
> 共 18 条,并与 `ROUTE_METHODS`(`index.js`)一起定义每条路径允许的方法。Worker 入口文件是 `src/index.js` > 共 19 条,并与 `ROUTE_METHODS`(`index.js`)一起定义每条路径允许的方法。Worker 入口文件是 `src/index.js`
>(`wrangler.toml`);`src/lb.js` 实现请求转发和有界故障转移(`SAF-LB-PASSTHRU`, >(`wrangler.toml`);`src/lb.js` 实现请求转发和有界故障转移(`SAF-LB-PASSTHRU`,
> `C-NO-LONG-CONN`)。 > `C-NO-LONG-CONN`)。
@ -317,9 +317,12 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读
| `/debug/*`(7 条路由)| 选择加入的远程调试界面(`SAF-DEBUG-GATE`);不在 `SAFE_ROUTES` 中,因此**只能**在后端源自己的地址上访问 | | `/debug/*`(7 条路由)| 选择加入的远程调试界面(`SAF-DEBUG-GATE`);不在 `SAFE_ROUTES` 中,因此**只能**在后端源自己的地址上访问 |
`GET, PUT /api/enabled`(`SAF-ENABLE-FLAG` 终止开关)**被转发**: `GET, PUT /api/enabled`(`SAF-ENABLE-FLAG` 终止开关)**被转发**:
管理 SPA 从 Worker URL 提供,Service 卡片读取它(`loadEnabled`)、切换开关写入它; 管理 SPA 从 Worker URL 提供,Service 卡片读取它(`loadEnabled`)、切换开关写入它。
两次调用都走 admin session 的 Bearer 认证,因此它不会暴露比已转发的 两个端点本来就都要求凭据,但凭据并不相同:`/api/enabled` 由 `config_authorized` 校验,
`/api/admin/session` 那一对更多的东西。 它接受共享 worker token **或**有效的 admin session;而 `POST /api/admin/session` 由
`worker_authorized` 对 admin 凭据(`CONFIG_ENCRYPTION_KEY`)校验,它本身就是发放 session
的入口。因此新增 `/api/enabled` 并没有打开新的凭据路径——`/api/admin/session` 本来就在
转发列表里。
后果:没有任何剩余路由承载外部业务流量,所以后端实例前面不需要第二个入口。 后果:没有任何剩余路由承载外部业务流量,所以后端实例前面不需要第二个入口。
SPA 的首次启动流程仍然无法通过 Worker 的 `/api/bootstrap` 完成——引导要么必须 SPA 的首次启动流程仍然无法通过 Worker 的 `/api/bootstrap` 完成——引导要么必须