docs: reanchor the notify.rs and state.rs line refs the backend moved

The retire-on-change commit inserted ~500 lines into notify.rs and ~110
into state.rs, shifting every anchor at or after the insertion point.
The ANCHOR audit only proves the referenced line exists and is
non-empty, so all of these passed while pointing at the wrong code:

  deployment.md/.zh-CN   register_telegram_webhook 1619 -> 1623
                         register_push             1464 -> 1468 (EN)
                         register_telegram_webhook 1439 -> 1623 (zh)
                         register_push             1315 -> 1468 (zh)
  retired.md/.zh-CN      registration_lock fmt      1328-1330 -> 1481-1483
                         register_push              1315 -> 1468
                         state.rs read_batch        491 -> 506
                         state.rs query_async       531 -> 546
                         state.rs XGROUP CREATE     499-507 -> 514-522
  opengaps.md/.zh-CN     get_reconcile_state        777 -> 792
                         set_reconcile_state        785 -> 800
                         acquire_lock               1076 -> 1130

Every target was verified against the source before editing: two of the
pre-existing anchors pointed inside a different function than the one
they named, and the EN deployment.md had both register anchors off while
the zh twin still carried the original pre-move numbers.

Also documents the new push:registration:current key in the Redis key
table (writer, readers, TTL, and why its literal suffix cannot collide
with the hex session_digest suffix) and carries the zh twin the EN
version already got. Mirror the retire-on-change contract in
deployment.zh-CN.md - the zh twin has its own callback section, which
the EN update skipped.
This commit is contained in:
anonsaber 2026-10-01 11:35:06 +08:00
parent 2089a95b79
commit baa6d8a6a1
8 changed files with 33 additions and 19 deletions

View File

@ -71,7 +71,7 @@ section in the configuration page. Enter the public HTTPS origin that receives c
the Worker URL when the gateway 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:1619` for Telegram and `src/notify.rs:1464` for Stalwart.
`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

View File

@ -64,9 +64,9 @@ Debian slim 运行时阶段;有关运行时内容,请参阅 [§8.2](#82-dock
## 4.1 注册 Telegram 和 Stalwart 回调
保存业务配置后,在配置页的“注册 Telegram 和 Stalwart 回调”区域操作。输入接收回调的公开 HTTPS origin:启用 Worker 时填写 Worker 地址,否则填写后端地址。页面会向 Telegram 注册 `/webhook/tg`,并向 Stalwart 注册 `/push/jmap`。凭据保留在后端,浏览器只提交回调 URL。Telegram 和 Stalwart 注册处理器分别位于 `src/notify.rs:1439` 与 `src/notify.rs:1315`。
保存业务配置后,在配置页的“注册 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 会更新 Telegram webhook,并为新的 URL 创建 Stalwart subscription。
Telegram 使用已保存的 webhook secret 和 `allowed_updates: ["message"]`。注册后,Stalwart 会自动完成验证回调和 verification-code 写回。可以安全地重复注册,重复注册同一 origin 是幂等的。更改回调 origin 会更新 Telegram(原子替换),对 Stalwart 则会在注册锁内先销毁旧订阅、再创建新订阅,因此旧 origin 不再收到推送。
## 5. 运行时配置

View File

@ -103,8 +103,8 @@ Single accounts are fixed by requirement (`REQ-SINGLE-ACCOUNT`) and never enter
**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)
- `state:jmap:since` is a **single global cursor**, no account dimension (read `src/state.rs:777`, write `src/state.rs:785`) - both accounts will overwrite each other's cursors, which is the first place to move.
- `lock:reconcile` is a **single global solo lock** (Redis implementation `src/state.rs:1076`) - the reconciliation of two accounts will be serialized by the same lock.
- `state:jmap:since` is a **single global cursor**, no account dimension (read `src/state.rs:792`, write `src/state.rs:800`) - both accounts will overwrite each other's cursors, which is the first place to move.
- `lock:reconcile` is a **single global solo lock** (Redis implementation `src/state.rs:1130`) - the reconciliation of two accounts will be serialized by the same lock.
- `dedup:jmap:{account_id}:{email_id}` **Already has the account segment** (`docs/reference.md` §1), the key shape does not need to be changed.
- The `ACCOUNT_ID` environment variable already exists and has been verified (leave it blank to take the session main account, and an error will be reported if the out-of-bounds value is `src/domain/jmap/client.rs:485`). The business configuration layer can also override `account_id` (`unwrap_or_else` of `src/config.rs:465` falls back). The configuration interface is ready, all that is missing is the sub-dimension status within the instance.

View File

@ -103,8 +103,8 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1),
**将来若真要做的最小改动面**(本轮核实过现状,供拆需求时直接用)
- `state:jmap:since` 是**单一全局游标**,无账户维度(读 `src/state.rs:777`、写 `src/state.rs:785`)——两个账户会互相覆盖游标,这是第一个要动的地方。
- `lock:reconcile` 是**单一全局单飞锁**(Redis 实现 `src/state.rs:1076`)——两个账户的对账会被同一把锁串行化。
- `state:jmap:since` 是**单一全局游标**,无账户维度(读 `src/state.rs:792`、写 `src/state.rs:800`)——两个账户会互相覆盖游标,这是第一个要动的地方。
- `lock:reconcile` 是**单一全局单飞锁**(Redis 实现 `src/state.rs:1130`)——两个账户的对账会被同一把锁串行化。
- `dedup:jmap:{account_id}:{email_id}` **已经带账户段**(`docs/reference.md` §1),键形无需改。
- `ACCOUNT_ID` 环境变量已存在且经校验(留空取 session 主账户,越界值在 `src/domain/jmap/client.rs:485` 报错),业务配置层也能覆盖 `account_id`(`src/config.rs:465` 的 `unwrap_or_else` 回落)。配置面已就绪,缺的只是实例内的分维度状态。

View File

@ -143,16 +143,24 @@ duplicate. Source comment:
| `push:subscription:id` | No EX | `remember_push_subscription_id` | current-subscription lookup |
| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status`; `pending` on verification request, `verified` on success, `disabled` on disable | **none** |
| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback`, called from `register_push` | `get_push_subscription_for_callback`; removed by `remove_push_subscription_for_callback` |
| `push:registration:current` | EX 604_800 | `set_push_registration_current`, called from `register_push` | `get_push_registration_current`; cleared by `clear_push_registration_current`, and by `disable_push` via `clear_push_registration_current_if` |
| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` | orphan sweep |
**Two distinct keys that are frequently confused. Read this before editing either.**
**Three distinct keys that are frequently confused. Read this before editing any of them.**
- `push:registration:{sha256(callback_url)}` is a **mapping from callback URL to
subscription id**, TTL seven days. It is not a lock.
- `push:registration:current` holds the **raw callback URL of the live subscription**, same
seven-day TTL. It is written last, after the new subscription is created. Registering with a
different URL destroys the previous subscription under the registration lock before creating
the new one, so the old origin stops receiving pushes; `disable_push` clears it only when the
disabled URL is the current one. Its suffix is the literal `current`, which cannot collide
with the `session_digest` hex suffix of the per-URL key. It is neither a mapping nor a lock.
- `lock:push-register:{sha256(callback_url)}` is the **single-flight registration lock**,
TTL 360 s. It is not a mapping.
They use the same digest but are separate keys with unrelated lifetimes and purposes.
The first two share the same TTL but are separate keys with unrelated purposes. The lock uses the
same digest as the per-URL mapping yet is a different key entirely.
**Annotation: `push:subscription:{id}:status` is write-only by design.**
The source carries an explicit note on `state.rs`:

View File

@ -142,16 +142,22 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本
| `推送:订阅:id` |没有 EX | `记住推送订阅 ID` |当前订阅查找 |
| `推送:订阅:{id}:状态` | 900 / 300 / 86_400(最低 1 强制执行)| `set_push_subscription_status`;验证请求时为“pending”,成功时为“verified”,禁用时为“disabled” | **无** |
| `推送:注册:{sha256(callback_url)}` |前 604_800 | `remember_push_subscription_for_callback`,从 `register_push` 调用 | `get_push_subscription_for_callback`;通过 `remove_push_subscription_for_callback` 删除 |
| `push:registration:current` |前 604_800 | `set_push_registration_current`,从 `register_push` 调用 | `get_push_registration_current`;通过 `clear_push_registration_current` 清除,`disable_push` 经 `clear_push_registration_current_if` 清除 |
| `push:orphan:{subscription_id}` |前 604_800 | `record_push_orphan` |孤儿扫荡|
**两个不同的键经常被混淆。在编辑之前请阅读本文。**
**三个不同的键经常被混淆。在编辑之前请阅读本文。**
- `push:registration:{sha256(callback_url)}` 是从回调 URL 到
订阅 ID**,TTL 7 天。它不是一把锁。
- `lock:push-register:{sha256(callback_url)}` 是**单次航班注册锁**,
订阅 ID 的**映射**,TTL 7 天。它不是一把锁。
- `push:registration:current` 存的是**当前存活订阅的回调 URL 原文**,同样 7 天 TTL。它在新订阅
创建之后最后写入。用一个不同的回调 URL 注册时,后端会在注册锁内先销毁上一个订阅、再创建
新订阅,因此旧 origin 不再收到推送;`disable_push` 只在被注销的正是当前地址时才清它。
其后缀是字面量 `current`,不会与 per-URL key 的 `session_digest` 十六进制后缀相撞。
它既不是映射也不是锁。
- `lock:push-register:{sha256(callback_url)}` 是**单飞注册锁**,
TTL 360 秒。它不是映射。
它们使用相同的摘要,但是是具有不相关生命周期和用途的单独密钥。
前两者 TTL 相同但是用途无关的独立键。锁与 per-URL 映射使用相同的摘要,但完全是不同的键。
**注释:`push:subscription:{id}:status` 设计为只写。**
来源对“state.rs”有明确的注释:

View File

@ -78,9 +78,9 @@ These names never exist in the code. Some of them appear in the directory tree o
| Directory scheme that splits Telegram access into four files "module entry/command parsing/session state/reply rendering" | Fictitious directory scheme | Drawn in the target directory tree, never created | Command parsing is in `parse_intent` (`src/worker.rs`); the session/authorization state is Redis TTL (the AI authorization state section of `docs/reference.md`); the rendering function is in `src/channel.rs` |
| `delivery:pending:{stream}` | Fictional key | Confused with Redis Streams' pending-entries list (PEL) - PEL is maintained internally by Redis and is not a writable key | Real key: `delivery:inflight:{stream}:{id}` (EX 60) and `delivery:committed:{stream}:{id}` (EX 604_800), see Delivery pipeline keys section of `docs/reference.md` |
| `check_config_reload` | Fictional function | Uncommitted name in draft, never made it into code | Hot updated to `refresh_business_config` (`src/notify.rs:1130`) |
| `push:registration:{sha256(callback_url)}` was once written as "360s registration solo lock" | Fact mislabeling | The key is 7d callback → subscription ID mapping | The real 360s solo lock is `lock:push-register:{sha256(callback_url)}` (`src/notify.rs:1328-1330`) |
| `push:registration:{sha256(callback_url)}` was once written as "360s registration solo lock" | Fact mislabeling | The key is 7d callback → subscription ID mapping | The real 360s solo lock is `lock:push-register:{sha256(callback_url)}` (`src/notify.rs:1481-1483`) |
| `MESSAGWEAVE_DOMAIN` was once written as "Worker whitelist is invalid when production is not configured" | Fictional assertion (unsubmitted draft/appeared in discussion, not documented) | This environment variable has zero hits in all positions; Worker whitelist is **unconditional fail-closed**: unknown path 404, method does not match 405, backend is missing or parsing failure 503 (`cloudflare-worker/src/index.js:77-92`) | No need to configure switches, the whitelist will always take effect |
| `read_batch` / `retry_or_dlq` "Redis may still return `Ok(())` when an error occurs, so the consumption loop will not exit due to a single failure" | Fictional assertion | The consumption entry is HTTP handler `worker` (`notify.rs:390`), **not a background loop**: full position `src/` zero hit `select!`, no signal processing, no resident worker process. `read_batch` (`state.rs:491`) throws the Redis error as it is (`state.rs:531`) via `?`, and the only discarded result is the `BUSYGROUP` idempotent protection of XGROUP `CREATE` (`state.rs:499-507`); `retry_or_dlq` is also thrown up via `?`. The caller returns `503 service_unavailable` + `retryable=true` for every `Err`, so the semantic premise of "the loop does not exit due to a single failure" itself is not true | None - the gap does not exist; `docs/opengaps.md` (original name `docs/roadmap.md`) "Code Gap" 4 → 3, `docs/design.md` "Known Boundary" same sentence has been deleted |
| `read_batch` / `retry_or_dlq` "Redis may still return `Ok(())` when an error occurs, so the consumption loop will not exit due to a single failure" | Fictional assertion | The consumption entry is HTTP handler `worker` (`notify.rs:390`), **not a background loop**: full position `src/` zero hit `select!`, no signal processing, no resident worker process. `read_batch` (`state.rs:506`) throws the Redis error as it is (`state.rs:546`) via `?`, and the only discarded result is the `BUSYGROUP` idempotent protection of XGROUP `CREATE` (`state.rs:514-522`); `retry_or_dlq` is also thrown up via `?`. The caller returns `503 service_unavailable` + `retryable=true` for every `Err`, so the semantic premise of "the loop does not exit due to a single failure" itself is not true | None - the gap does not exist; `docs/opengaps.md` (original name `docs/roadmap.md`) "Code Gap" 4 → 3, `docs/design.md` "Known Boundary" same sentence has been deleted |
---
@ -89,7 +89,7 @@ These names never exist in the code. Some of them appear in the directory tree o
| Build the channel layer into a directory module (including module entry file) | Fictitious directory | The channel layer is a file called `src/channel.rs` | None |
| `notify::push_handler` / `notify::worker` / `notify::reconcile` as module paths | Fictitious module paths | These are free functions within `src/notify.rs` (`jmap_push` / `worker` / `reconcile`), not module paths | None |
| `mod_dedup` / `mod_streams` / `mod_sincestate` | Fictional module name | `state.rs` / `notify.rs` are tile files, no submodules; deduplication and Streams logic exist as free functions | None |
| `PushVerification` type | Fictitious type | Undefined; `register_push` (`notify.rs:1315`) handles callback URL and verification code writeback inline | None |
| `PushVerification` type | Fictitious type | Undefined; `register_push` (`notify.rs:1468`) handles callback URL and verification code writeback inline | None |
| `CancellationToken` | fictitious type | unused; no graceful shutdown, no signal handling (`src/` zero hits `tokio::signal` / `ctrl_c`) | None |
| `Preview` type / 4000 character long email protection / `[Continue to view original text]` button / `/llm-fallback` button | Fictional type and UI | All not implemented; after authorization, the full text will be handed over to LLM, and if it fails, the first 300 characters will be returned, without any truncation mark or button | None |
| `LlmErr` (5 variants) | Fictional enum | Real is `AiError`, only 3 variants (`InvalidEndpoint` / `Request` / `Response`) | None |

View File

@ -78,9 +78,9 @@
| 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
| `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 |
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1130`) |
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1328-1330`) |
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1481-1483`) |
| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 |
| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:390`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:491`)经 `?` 把 Redis 错误原样上抛(`state.rs:531`),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(`state.rs:499-507`);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:390`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:506`)经 `?` 把 Redis 错误原样上抛(`state.rs:546`),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(`state.rs:514-522`);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
---
@ -89,7 +89,7 @@
| 把渠道层建成目录模块(含模块入口文件) | 虚构目录 | 渠道层就是 `src/channel.rs` 一个文件 | 无 |
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:1315`)内联处理回调 URL 与验证码回写 | 无 |
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:1468`)内联处理回调 URL 与验证码回写 | 无 |
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |