diff --git a/AGENTS.md b/AGENTS.md index ea7fbb7..0a12fac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,14 @@ - **行号锚点必须指向对应内容**,不能只指向「文件里那一行」。文档写「某文件第 120 行是 重试上限」,第 120 行就必须是重试上限;行号漂移而描述跟着跑,比行号写错更危险。 - **校验器只能证明「该行号存在且非空」,不能证明该行内容符合描述。** 内容级核对必须人工读。 +- **文档引用代码一律用符号名,不写行号。** 唯一例外是 `docs/deployment.md`(运维 runbook, + 事故中按行跳转)。行号随任何无关改动重排,而符号名稳定;`docs/reference.md` 曾有 56 个 + `notify.rs` 锚点,其中约 40 个落在闭合括号、注释行或错误的符号上,而门禁报 0 错, + 2026-09-29 决定彻底不维护行号。 +- 需要定位到行时用 `grep` 符号名,不要用文档里的行号做判断依据。 +- `scripts/docs_check/audit_anchor_semantics.py` 是**独立脚本**,没接进 `run_all.sh`, + 恒 `exit 0`,只打印候选。它抓「锚点指向的行跟所声称的符号毫无关系」这类漂移,但抓不到 + 一行以内的 ±1/±2 off-by-one——那仍然要靠人读。候选数下降说明文档在好转,不等于锚点全对。 ### 2.6 分层与依赖方向 diff --git a/docs/deployment.md b/docs/deployment.md index f4201d3..abdf7cf 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -74,11 +74,11 @@ `hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在 `src/main.rs:217-264`。 -缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1625`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 +缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1724`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 **鉴权** -7 条路由全部要求 `Authorization: Bearer `,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:496`),因此令牌是**常数时间比较**(`src/notify.rs:1416`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 +7 条路由全部要求 `Authorization: Bearer `,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:496`),因此令牌是**常数时间比较**(`src/notify.rs:1512`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 **7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取) @@ -99,7 +99,7 @@ **三条约束,部署时务必确认** - **绝不回显凭据值**:`debug_config`(`src/debug.rs:94`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。 -- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1422`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:233-236`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:241-246`)。 +- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1518`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:233-236`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:241-246`)。 - **不在 Worker 白名单内,只能直连 origin**:网关的 16 条安全路由(`cloudflare-worker/src/backends.js:9-26`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:82-85`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 > **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性 @@ -181,13 +181,13 @@ curl -fsS -X POST "https://<你的平台URL>/api/push/register" \ ``` 注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码, -后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1163`)。 +后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1259`)。 - `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request` - (`src/notify.rs:1175-1177`)。 + (`src/notify.rs:1271-1273`)。 - 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。 - 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁 - (`src/notify.rs:1178` / `src/notify.rs:1186`),撞锁返回 409 `conflict`。 + (`src/notify.rs:1283` / `src/notify.rs:1286`),撞锁返回 409 `conflict`。 - 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`,在等 Stalwart 回来验证。 反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。 - 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart @@ -381,12 +381,12 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 ### 6.2 Redis Streams / worker(MOD-STREAMS) - Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞 -- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:381`,路由 `src/notify.rs:1608`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。 -- 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:397`;批量读取在 `src/state.rs:444`),逐条处理后确认消费(`src/state.rs:324`);处理失败转重试 / DLQ(`src/notify.rs:478`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。 +- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:381`,路由 `src/notify.rs:1705`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。 +- 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:398`;批量读取在 `src/state.rs:444`),逐条处理后确认消费(`src/state.rs:324`);处理失败转重试 / DLQ(`src/notify.rs:479`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。 - 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底) ### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE) -- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP `Email/changes` 分页并将事件幂等入 Streams;全部入队成功后才持久化 `state:jmap:since`,依赖失败返回 `503` 供调度器重试。**对账只入队、不消费**——它全程不调用 `read_batch`(全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:410`,属于 worker);排空由另一个端点 `POST /worker` 承担,两个都要排(§6.3.1)。 +- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP `Email/changes` 分页并将事件幂等入 Streams;全部入队成功后才持久化 `state:jmap:since`,依赖失败返回 `503` 供调度器重试。**对账只入队、不消费**——它全程不调用 `read_batch`(全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:411`,属于 worker);排空由另一个端点 `POST /worker` 承担,两个都要排(§6.3.1)。 - **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。 - 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。 - 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。 @@ -394,10 +394,10 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 #### 6.3.1 用户侧调度示例(copy-paste 可用) -端点有两个:`POST /reconcile`(路由 `src/notify.rs:1607`)与 `POST /worker`(路由 -`src/notify.rs:1608`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、 +端点有两个:`POST /reconcile`(路由 `src/notify.rs:1704`)与 `POST /worker`(路由 +`src/notify.rs:1705`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、 再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用 -`read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:410`。 +`read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:411`。 **只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。 最小可运行版本(推荐起点): @@ -409,7 +409,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达 | 鉴权 | Bearer + `reconcile_token` | Bearer + `worker_token` | | 可走 Worker 吗 | 可以(在 `SAFE_ROUTES` 内) | 不行,必须直连后端 origin | | 幂等 | `SET NX lock:reconcile 300s` 单飞锁,撞锁回 409 | 无单飞锁,靠逐条 `delivery:*` 去重声明防重投 | -| 单次上限 | 100 条变更(`src/notify.rs:293`) | 10 条消息(`src/notify.rs:397`) | +| 单次上限 | 100 条变更(`src/notify.rs:293`) | 10 条消息(`src/notify.rs:398`) | | 常态返回 | 204 | 204(**不能当成功信号**) | 两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret @@ -452,7 +452,7 @@ curl -sS -o /dev/null -w '%{http_code}\n' \ - 两条之间用 `;` 而不是 `&&`:对账撞锁拿 409、或临时 503,都不该阻断排空——上一次对账 入队的消息同样需要被消费。 - 排空那条必须是**后端 origin 的直连地址**,不是 Worker 域名。 -- 请求体可带 `{"batch":N}`;`N` 会被压回上限 10,不会因传大值而报错(`src/notify.rs:397`)。 +- 请求体可带 `{"batch":N}`;`N` 会被压回上限 10,不会因传大值而报错(`src/notify.rs:398`)。 传非法 JSON 才是 400 `invalid_request`(`src/notify.rs:402`)。 Kubernetes CronJob 示例: @@ -501,19 +501,19 @@ spec: | 状态码 | 含义 | 处理 | | --- | --- | --- | | `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 | -| `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:491` 无条件返回 | +| `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:492` 无条件返回 | | `/worker` 400 | 请求体不是合法 JSON(`src/notify.rs:402`) | 修 curl;空请求体合法,等价于取默认 10 条 | | `/reconcile` 401 | `Authorization` 头缺失或 `reconcile_token` 不匹配(`src/notify.rs:284`) | 检查 cron 的 token 值;重启不解决 | | `/worker` 401 | Bearer 值与 `worker_token` 不匹配(`src/notify.rs:393`) | 同上;`/worker` 无 admin-session 兜底 | -| `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:289` 取锁,`src/notify.rs:345` 返回) | 直接跳过本次 tick,不要重试 | +| `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:290` 取锁,`src/notify.rs:346` 返回) | 直接跳过本次 tick,不要重试 | | 503 | 业务配置此刻不可用,或 Redis / JMAP 请求失败 | 可重试;响应带 `Retry-After: 30`(`src/notify.rs:374`) | 重试建议: - **不要**在客户端实现重试循环。**调度器每 5 分钟触发一次已经足够**,因为: - 单次调用**幂等**:`/reconcile` 有 `SET NX lock:reconcile 300s` 单飞锁 - (`src/notify.rs:289`,长对账中每 90s 续租 `src/notify.rs:302`,成功后释放 - `src/notify.rs:338`);`/worker` 靠逐条去重声明防重投,重复触发无害。 + (`src/notify.rs:290`,长对账中每 90s 续租 `src/notify.rs:303`,成功后释放 + `src/notify.rs:339`);`/worker` 靠逐条去重声明防重投,重复触发无害。 - 客户端拿到 `409 conflict` 时**不要重试**,直接跳过本次 tick——锁在 TTL 或任务完成后 自然释放,下一次调度会重试。 - 超时设置建议 ≥ 单飞锁 TTL 的 1/3(即 ≥ 100s)以吸收长对账,但客户端超时不应超过 @@ -521,7 +521,7 @@ spec: - 如果你的调度器自带重试能力,把它限制为:**只对 5xx 与超时重试**,指数退避 2s / 4s / 8s,上限 60s,总次数 ≤ 3;`204` 与 `409` **永远不重试**。调度器没有自带能力 就不要额外实现——下一个 tick 自然会重试。 -- **积压会自然堆到下一次 tick。** `/worker` 单次最多消费 10 条(`src/notify.rs:397`); +- **积压会自然堆到下一次 tick。** `/worker` 单次最多消费 10 条(`src/notify.rs:398`); 积压超过 10 条时需要连续多个 tick 才能清完。想更快就调高 cron 频率,**不要**靠加长 curl 超时——那只会抬高挂死概率,不会让更多消息被消费。 - `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。 @@ -530,7 +530,7 @@ spec: - **`/reconcile` 不会返回 400**:它没有请求体解析,传了 body 会被静默忽略,所以不要以为要传 `since`。 - 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 - `src/notify.rs:302`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 + `src/notify.rs:303`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 `409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS` (默认 `2`),不受影响。`POST /worker` 走不了 Worker,不受这些参数影响。 diff --git a/docs/opengaps.md b/docs/opengaps.md index 978f81a..dde8271 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -18,7 +18,7 @@ 上一轮真机联调已经关掉了两条阻塞: - **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。真实 Telegram 流量已驱动过 `/webhook/tg`。 -- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1140`(JMAP 回调入队)与 `src/worker.rs:394`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。 +- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1236`(JMAP 回调入队)与 `src/worker.rs:394`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。 两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。 @@ -52,14 +52,14 @@ | --- | --- | --- | --- | | `lock:reconcile` | 对账锁 | 300s | `src/notify.rs:290` | | `lock:reconcile` | 对账锁心跳续租 | 90s | `src/notify.rs:303` | -| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1188` | -| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1098` | +| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1283` | +| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1192` | | `dedup:tg:{update_id}` | Telegram 更新去重 | 86_400s | `src/notify.rs:250` | -| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(回调入队) | 86_400s | `src/notify.rs:1137` | +| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(回调入队) | 86_400s | `src/notify.rs:1231` | | `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(对账增量入队) | 86_400s | `src/worker.rs:394` | | `delivery:inflight:{stream}:{message.id}` | 投递在途守卫 | 60s | `src/notify.rs:447` | | `delivery:committed:{stream}:{message.id}` | 投递幂等 | 604_800s | `src/notify.rs:459` | -| `push:subscription:{id}:status` | push 订阅 `disabled` 标记 | 86_400s | `src/notify.rs:1313` | +| `push:subscription:{id}:status` | push 订阅 `disabled` 标记 | 86_400s | `src/notify.rs:1408` | **B. 内嵌 Lua 脚本 — 1 个写入点 / 1 个值** diff --git a/docs/reference.md b/docs/reference.md index 6d38d32..9c3020c 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -3,8 +3,10 @@ > **This file is the single source of truth for verifiable facts.** > When any other document disagrees with this one, this one wins. > -> **Verified against commit `b2dbe7c`.** Line numbers in this file were read from that -> commit with the working tree clean. +> **This file carries no source line numbers, by design.** They are not part of the +> verification: this file documents *which symbol* does *what*, and line numbers are +> renumbered by every unrelated edit to the same file. `docs/deployment.md` keeps its +> line numbers, because that file is an operator runbook read mid-incident. > > **Maintenance responsibility.** Any change to the public API of `src/config.rs`, > `src/state.rs`, `src/worker.rs`, or `src/notify.rs`, or to **any Redis key name or TTL**, @@ -14,9 +16,13 @@ Conventions used throughout: - **TTL** is in seconds. "No EX" means the key never expires and lives until explicitly deleted. -- **Writer / Reader** name the code symbol that performs the operation; the line number is - an annotation for fast navigation, not an identity. -- Only this file carries line numbers. All other documents reference symbols by name. +- **Writer / Reader** name the code symbol that performs the operation. Symbol names are + the identity; this file deliberately records no line numbers (see the note above), so + it stays true across renumbering. + +--- + + --- @@ -26,35 +32,34 @@ Conventions used throughout: | Key | TTL | Writer | Reader | |---|---|---|---| -| `config:business` | No EX | `set_business_config` (state.rs:577, key at state.rs:581); first-time write via `SET NX` in `initialize_business_config` (state.rs:592) | `get_business_config` (state.rs:566) | -| `config:business:revision` | No EX | atomic `INCR` on every config write (state.rs:585); `SET 1 NX` on first-time init (state.rs:605) | `business_config_revision` (state.rs:640) | -| `config:outbound` | No EX | `set_outbound_config` (state.rs:553) | `get_outbound_config` (state.rs:537) | -| `config:enabled` | No EX | `set_enabled` (state.rs:658) | `is_enabled` (state.rs:649) | -| `config:admin_session` | EX 900 | `put_admin_session` (state.rs:615, key at state.rs:618) | `admin_session_valid` (state.rs:627); cleared by `revoke_admin_session` (state.rs:636) | +| `config:business` | No EX | `set_business_config`; first-time write via `SET NX` in `initialize_business_config` | `get_business_config` | +| `config:business:revision` | No EX | atomic `INCR` on every config write; `SET 1 NX` on first-time init | `business_config_revision` | +| `config:outbound` | No EX | `set_outbound_config` | `get_outbound_config` | +| `config:enabled` | No EX | `set_enabled` | `is_enabled` | +| `config:admin_session` | EX 900 | `put_admin_session` | `admin_session_valid`; cleared by `revoke_admin_session` | Notes: - `config:business:revision` is an **atomic Redis `INCR`**, not a read-modify-write. Every - request-bearing entry point calls `refresh_business_config` (notify.rs:718), which only - rebuilds the worker when the remote revision exceeds the cached local value - (notify.rs:723-725) — that guard is what keeps a stale or malformed snapshot from turning - into a rebuild loop. + request-bearing entry point calls `refresh_business_config`, which only + rebuilds the worker when the remote revision exceeds the cached local value — that + guard is what keeps a stale or malformed snapshot from turning into a rebuild loop. - `config:admin_session` carries no TTL metadata beyond the literal `EX 900` passed at write time; the handler reports the same window in its response body as `expires_in: 900` - (notify.rs:788). + on the handler side. - `config:enabled` is consulted as a gate; missing or `false` keeps business processing off. ### 1.2 AI consent | Key | TTL | Writer | Reader | |---|---|---|---| -| `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` (state.rs:505; key at state.rs:507, `EX` at state.rs:512) | `ai_consent_until` (state.rs:519); cleared by `clear_ai_consent` (state.rs:528) | +| `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` | `ai_consent_until`; cleared by `clear_ai_consent` | The stored value is an **absolute Unix expiry timestamp**, not a duration; the key's `EX` -carries the same duration, so the key removes itself (state.rs:509-514). +carries the same duration, so the key removes itself. Trigger words are exact Chinese literals with **no English aliases** — the in-app help text -enforces this itself (`worker.rs:614`): +enforces this itself (in-app help text in `worker.rs`): | Input | TTL | Label | |---|---|---| @@ -66,12 +71,12 @@ enforces this itself (`worker.rs:614`): | `/ai off`, `关闭 ai`, `撤销授权`, `停止摘要` | — | revoke | The `/ai …` slash forms are exact matches on the whole message; the Chinese phrases are -substring matches anywhere in the message (`worker.rs:546-566`, `:581-586`). +substring matches anywhere in the message (both inside `parse_intent`, `worker.rs`). `1小时` / `临时1小时` / `直到撤销(最长365天)` are **category labels, not trigger words**: typing a label alone grants nothing. Consent never auto-renews. -The same `parse_intent` (worker.rs:543, `Intent` at worker.rs:525) routes `/search <关键词>` +The same `parse_intent` (`worker.rs`, variants in `Intent`) routes `/search <关键词>` and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, never a substring) to `Intent::Search`. Search grants no consent and writes no Redis key. @@ -79,11 +84,11 @@ and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, neve | Key | TTL | Writer | Reader | |---|---|---|---| -| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:283, `XADD` at state.rs:285); call sites notify.rs:253 (TG webhook) and notify.rs:1140 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:394) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:409-410) | -| `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:445, `claim_dedup` at notify.rs:446 | `release_dedup` on completion (notify.rs:459, 466, 475) | -| `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:424, `claim_dedup` at notify.rs:458 | `dedup_exists` before send (notify.rs:425) | -| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:481, `INCR` state.rs:485, `EXPIRE` state.rs:486) | reclaim path | -| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:488; name built at notify.rs:429 (`max_attempts` 3, same call) | **nothing in code reads it** | +| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (a `XADD`), called from `telegram_webhook` and `jmap_push`; reconcile path via `claim_dedup_and_enqueue` (`worker.rs`) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` | +| `delivery:inflight:{stream}:{id}` | EX 60 | key built and claimed with `claim_dedup` in the delivery path | `release_dedup` on completion | +| `delivery:committed:{stream}:{id}` | EX 604_800 | key built and claimed with `claim_dedup` in the delivery path | `dedup_exists` before send | +| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq`'s Lua script (`INCR` then `EXPIRE` on the same key) | reclaim path | +| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | the same Lua script's `XADD`; the name is passed in from `retry_or_dlq`'s call site in the worker (with `max_attempts` 3) | **nothing in code reads it** | There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams pending-entries list (PEL), which Redis maintains internally. @@ -97,17 +102,17 @@ acknowledgement. The DLQ is **append-and-ack**: the same Lua script that appends to `stalwart:jmap:dlq` / `stalwart:telegram:dlq` also `XACK`s the message out of the source stream, so increment, DLQ -append and source acknowledgement are atomic (state.rs:485-489). No code path reads the DLQ +append and source acknowledgement are atomic, all inside that one Lua script. No code path reads the DLQ back — replay is an operator action, not a service feature. ### 1.4 Idempotency and rate limits | Key | TTL | Writer | Reader | |---|---|---|---| -| `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:248, `claim_dedup` at notify.rs:249, `release_dedup` at :258 | — | -| `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:1135, `claim_dedup` at notify.rs:1136, `release_dedup` at notify.rs:1145; same key rebuilt at worker.rs:390 and claimed with `claim_dedup_and_enqueue` at worker.rs:394 | — | -| `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:1096, `claim_dedup` at notify.rs:1097 (`push_verify_rate_limited` at :1107) | — | -| `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:676; key at state.rs:679, `SET` without `EX`) | `get_reconcile_state` (state.rs:668) | +| `dedup:tg:{update_id}` | EX 86_400 | built and claimed with `claim_dedup` in `telegram_webhook`, released with `release_dedup` on a failed enqueue | — | +| `dedup:jmap:{account}:{email}` | EX 86_400 | built and claimed with `claim_dedup` in `jmap_push`; the same key is rebuilt on the reconcile path and claimed with `claim_dedup_and_enqueue` (`worker.rs`) | — | +| `ratelimit:push-verify:{subscription}` | EX 30 | built and claimed with `claim_dedup` in `register_push`; a `false` claim is the `429 push_verify_rate_limited` branch | — | +| `state:jmap:since` | **No EX** | `set_reconcile_state` (a `SET` with no `EX`) | `get_reconcile_state` | `state:jmap:since` is the only state key that survives indefinitely without an explicit delete, by design: it is the reconciliation cursor, and expiring it would force a full @@ -117,12 +122,12 @@ re-baseline on the next restart. | Key | TTL | Writer | Reader | |---|---|---|---| -| `lock:reconcile` | 300 | `acquire_lock` (notify.rs:289) | `renew_lock` heartbeat 90 (notify.rs:302) | -| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:907) | `release_lock` (notify.rs:923, 937, 950, 967, 984, 994) | +| `lock:reconcile` | 300 | `acquire_lock` in `reconcile` | `renew_lock` from the heartbeat task (90 s) | +| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` in `register_push` | `release_lock` on every exit path of `register_push` | **Why the registration lock is 360 s and not 300 s.** The lock must outlive the longest configured JMAP request timeout (300 s), otherwise a slow registration could admit a -duplicate. Source comment, notify.rs:905-906: +duplicate. Source comment: > The lock must outlive the configured 300s maximum JMAP request timeout; this prevents a > slow create from admitting a duplicate. @@ -131,11 +136,11 @@ duplicate. Source comment, notify.rs:905-906: | Key | TTL | Writer | Reader | |---|---|---|---| -| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:686, called at notify.rs:834) | `push_subscription_verified` (state.rs:703) | -| `push:subscription:id` | No EX | `remember_push_subscription_id` (state.rs:716) | current-subscription lookup | -| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:726, key at state.rs:734); pending at notify.rs:961, verified at notify.rs:846, disabled at notify.rs:1032 | **none** | -| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback` (state.rs:779, key at state.rs:786, `EX` at state.rs:788; called at notify.rs:978) | `get_push_subscription_for_callback` (state.rs:768); removed by `remove_push_subscription_for_callback` (state.rs:795) | -| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:752) | orphan sweep | +| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription`, called from `register_push` | `push_subscription_verified` | +| `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:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` | orphan sweep | **Two distinct keys that are frequently confused. Read this before editing either.** @@ -147,7 +152,7 @@ duplicate. Source comment, notify.rs:905-906: They use the same digest but are separate keys with unrelated lifetimes and purposes. **Annotation: `push:subscription:{id}:status` is write-only by design.** -The source carries an explicit note at state.rs:211: +The source carries an explicit note on `state.rs`: > `push:subscription:{id}:status` is currently write-only @@ -185,13 +190,12 @@ field. `request_id` is always present. `Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal string `"30"`: -- `error_response` (`notify.rs:371-379`, inserted at `:375`) sets it **only when its `retry` - flag is true**. The readiness failure path (`notify.rs:210`) passes `retry = true`, so `/ready` - is covered by that rule rather than by a special case. -- `error_response_with_id` (`notify.rs:1464-1471`, inserted at `:1467`) sets it - **unconditionally**. It is used only for the push register/disable failures - `push_state_unavailable` and `push_destroy_failed` (`notify.rs:1233`, `:1250`, `:1267`, - `:1304`), which are always `503`. +- `error_response` sets it **only when its `retry` flag is true**. The readiness failure + path passes `retry = true`, so `/ready` is covered by that rule rather than by a + special case. +- `error_response_with_id` sets it **unconditionally**. It is used only for the push + register/disable failures `push_state_unavailable` and `push_destroy_failed`, which are + always `503`. `GET /api/status` is the exception that reports setup state with a non-envelope body. @@ -199,13 +203,12 @@ string `"30"`: ## 3. Backend routes -All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap` -(`notify.rs:1575`), with the routes wired at `notify.rs:1604-1622`. The three static SPA routes -in the last table row are the exception: they live in `web::router()` (`src/web.rs:19-24`) and -are merged into the router via `.merge(web::router())`: the setup-mode router at `src/main.rs:76`, -the production router at `src/main.rs:195-205`. The setup-mode router at `src/main.rs:76` mounts -only three of this table — `/healthz`, `/ready` and `/api/status` — and no business or admin route; -§5.1 explains why that is deliberate. +All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap`. +The three static SPA routes in the last table row are the exception: they live in +`web::router()` (`src/web.rs`) and are merged into the router via `.merge(web::router())` — +in the setup-mode router and again in the production router. The setup-mode router mounts +only three of this table — `/healthz`, `/ready` and `/api/status` — and no business or +admin route; §5.1 explains why that is deliberate. | Method | Path | Handler | |---|---|---| @@ -227,13 +230,13 @@ only three of this table — `/healthz`, `/ready` and `/api/status` — and no b | GET | `/api/status` | `setup_status` | | GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `script` / `styles` | -The remote-debug surface (`src/debug.rs:72-80`) is merged **only when the debug surface is -requested — `--debug` on the command line or a truthy `DEBUG_ENABLED` env var — and `DEBUG_TOKEN` -is non-empty** (`SAF-DEBUG-GATE`, gate logic `src/main.rs:15-25`, wired at `src/main.rs:102-114`, -`src/notify.rs:1625`); otherwise none of these routes exist and requests fall through to -axum's generic `404`, not a 401. All seven require `Authorization: Bearer `, -checked by `debug_authorized` (`src/debug.rs:45`), which delegates to the production -`worker_authorized` so the comparison is constant time (`src/notify.rs:1416`). +The remote-debug surface (`debug_router`, `src/debug.rs`) is merged **only when the +debug surface is requested — `--debug` on the command line or a truthy `DEBUG_ENABLED` +env var — and `DEBUG_TOKEN` is non-empty** (`SAF-DEBUG-GATE`; the gate is read in +`src/main.rs` before either router is built). Otherwise none of these routes exist and +requests fall through to axum's generic `404`, not a 401. All seven require +`Authorization: Bearer `, checked by `debug_authorized`, which delegates to +the production `worker_authorized` so the comparison is constant time. | Method | Path | Handler | |---|---|---| @@ -254,13 +257,13 @@ Response conventions: completeness (`setup_missing` empty), Redis reachability, and two upstream probes — `GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and `GET https://api.telegram.org/bot/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms - (`notify.rs:122`) and run **in parallel** (`tokio::join!`, `notify.rs:196`), so the worst case + and run **in parallel** (`tokio::join!`), so the worst case is a single timeout, about 3s. On success the body is a report whose `jmap`/`telegram` fields are real probe results. The probes are plain reusable functions (`probe_jmap_session`, `probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated. The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw session URL unauthenticated would report not-ready forever and make ingress stop routing. -- `GET /api/status` (**always 200**, `notify.rs:215-224`) returns +- `GET /api/status` (**always 200**) returns `{"ready": , "mode": "configured"|"configuration-setup", "missing": [...], "version": ""}`. `ready` is `false` and `missing` lists the absent required keys (`REDIS_URL`, `CONFIG_ENCRYPTION_KEY`) when a variable is absent — the route itself never errors, so it is @@ -268,27 +271,27 @@ Response conventions: (`+`) and is how the SPA footer proves a deploy landed. - `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business config is not loaded or there is no outbound client — `service_unavailable` with - `Retry-After: 30` (`src/debug.rs:56-58`). The three probe endpoints instead report failure + `Retry-After: 30`. The three probe endpoints instead report failure **inside a 200 body** (`{"ok":false,"detail":...}`), so an unreachable upstream never looks like an outage. `POST /debug/notify` also returns `403 chat_not_allowed` when the chat is - outside a *non-empty* allowlist (`src/debug.rs:233-236`) and `502 telegram_send_failed` on - a send failure (`src/debug.rs:64-66`). + outside a *non-empty* allowlist, and `502 telegram_send_failed` on + a send failure. - Static assets are served with a strict CSP; see §7. --- ## 4. Gateway vs backend route matrix -> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES` -> (backends.js:9-26), 16 entries, alongside `ROUTE_METHODS` (index.js:46-63) +> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`, +> 16 entries, alongside `ROUTE_METHODS` (`index.js`) > which fixes one method set per path. The worker entry point is `src/index.js` -> (wrangler.toml:20); `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`). The gate is **unconditional and fail-closed**. The worker reads no configuration switches at -all: a path missing from `SAFE_ROUTES` (backends.js:9-26) returns **404** (index.js:82-85), a registered -path with the wrong method returns **405** (index.js:86-89), and a missing or unparseable -backend pool returns **503** rather than passing the request through (index.js:91-99). +all: a path missing from `SAFE_ROUTES` returns **404**, a registered +path with the wrong method returns **405**, and a missing or unparseable +backend pool returns **503** rather than passing the request through. **Forwarded by the worker (16):** @@ -304,13 +307,13 @@ backend pool returns **503** rather than passing the request through (index.js:9 | Path | Why it is absent from the gateway | |---|---| | `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path | -| `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd, `notify.rs:381`; the cron is spelled out in deployment.md §6.3.1); not part of the public gateway path | +| `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd; the cron is spelled out in deployment.md §6.3.1); not part of the public gateway path | | `GET /healthz` | Liveness is aggregated by the gateway itself | | `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); absent from `SAFE_ROUTES`, so it is reachable **only** by talking to the backend origin directly | `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` -reads it at `web/config.js:676`, the toggle writes it at `:1048`); both calls are +reads it, the toggle writes it); both calls are admin-session Bearer-auth'd, so the exposure is identical to the already-forwarded `/api/admin/session` pair. @@ -320,10 +323,10 @@ needed in front of the backend instances. The SPA's first-boot flow still cannot directly, or the bootstrap path must be added to the gateway allowlist. `POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to -proxy: the callback URL is supplied by the client in the request body (`notify.rs:875`, -validated as a URL at :1172), and every push subscription record is written to and read back -from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback` -at `state.rs:768`), so it does not matter which backend instance the worker picks. Push +proxy: the callback URL is supplied by the client in the request body and +validated as a URL before anything is written, and every push subscription record is +written to and read back from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback`), +so it does not matter which backend instance the worker picks. Push registration therefore no longer requires hitting a specific backend address — see deployment.md §10.5. @@ -337,11 +340,11 @@ Three distinct layers. They are not interchangeable. | Variable | Required | Default | Source | |---|---|---|---| -| `REDIS_URL` | yes | — | read at main.rs:40 | -| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:64 | -| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:45-48. The listener binds it directly (main.rs:49-52); `Config` carries no port field | -| `DEBUG_ENABLED` | no | — | `std::env::var` at main.rs:104; truthy values are `1`/`true`/`TRUE`/`True`/`yes`/`YES` (exact match); enables the debug surface on its own, paired with `--debug` as an alternative (`SAF-DEBUG-GATE`) | -| `DEBUG_TOKEN` | no | — | `std::env::var` at main.rs:107, effective only once the debug surface is requested via `DEBUG_ENABLED` or `--debug` (`SAF-DEBUG-GATE` / `SAF-DEBUG-ORIGIN-ONLY`) | +| `REDIS_URL` | yes | — | read at startup in `src/main.rs` | +| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` | +| `PORT` | no | `8080` | `unwrap_or(8080_u16)`. The listener binds it directly; `Config` carries no port field | +| `DEBUG_ENABLED` | no | — | truthy values are `1`/`true`/`TRUE`/`True`/`yes`/`YES` (exact match); enables the debug surface on its own, paired with `--debug` as an alternative (`SAF-DEBUG-GATE`) | +| `DEBUG_TOKEN` | no | — | effective only once the debug surface is requested via `DEBUG_ENABLED` or `--debug` (`SAF-DEBUG-GATE` / `SAF-DEBUG-ORIGIN-ONLY`) | `RUN_MODE` **no longer exists**: the identifier was removed together with the legacy `Config::from_env()` environment parser (registration in `docs/retired.md`). Both webhook @@ -349,10 +352,10 @@ and reconcile traffic share one router and reconcile is a standalone `POST /reco endpoint, so the variable never changed any runtime behaviour — there is now nothing to set. **Missing a required variable does not crash the process.** It logs a warning and serves -`router_configuration_setup` (`notify.rs:1500-1520`), which mounts only `/api/status`, +`router_configuration_setup`, which mounts only `/api/status`, `/ready` and `/healthz` on top of the static SPA. The business and admin routes from §3 are not -registered at all: `admin_token` is empty, so `constant_time_eq` (`notify.rs:1416`) would reject -every candidate forever, and `MemoryState` (`state.rs:875`) could not persist a bootstrap +registered at all: `admin_token` is empty, so `constant_time_eq` would reject +every candidate forever, and `MemoryState` could not persist a bootstrap write anyway. Posting to `/api/bootstrap` in this mode yields `404` (route absent), not a `401` that reads as "retry with a better credential". The container stays up and answers the status surface while doing no business work. This is a deliberate fail-closed-to-setup posture, and it @@ -432,28 +435,28 @@ env surface and are listed only so a stale deployment script can be recognised a `TG_WEBHOOK_SECRET` · `WORKER_TOKEN` The `JMAP_*` family is exactly three names. In production the account id arrives as the -`accountId` request-body field (notify.rs:1122) and is held on the client as -`account_id` (src/domain/jmap/client.rs:14); the only remaining environment reads of these -four names are inside the `#[ignore]` real-server smoke test at -src/domain/jmap/client.rs:517-526, which is not part of the production configuration surface. +`accountId` request-body field on `jmap_push` and is held on the client as +`account_id`; the only remaining environment reads of these +four names are inside the `#[ignore]` real-server smoke test in +`src/domain/jmap/client.rs`, which is not part of the production configuration surface. The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as `TELEGRAM_BOT_TOKEN` or similar is a documentation error, not a supported variable. **Trust root.** The SPA admin credential is `CONFIG_ENCRYPTION_KEY` itself: read at startup -(main.rs:63) and held as `admin_token` (main.rs:92), it is checked in constant time by -`worker_authorized` (notify.rs:496; compare at notify.rs:1136) at the top of both -`POST /api/bootstrap` (notify.rs:605) and `POST /api/admin/session` (notify.rs:775). It is +and held as `admin_token`, it is checked in constant time by +`worker_authorized` at the top of both +`POST /api/bootstrap` and `POST /api/admin/session`. It is only compared against the request bearer — never echoed, logged, or stored. The session issued by `/api/admin/session` is a freshly generated random 32-byte hex token -(notify.rs:779-788), never the credential; only its digest is retained in Redis under +(generated per call), never the credential; only its digest is retained in Redis under `config:admin_session`. `REDIS_URL` is the Redis connection string alone: an ACL password in it, if any, authenticates the Redis connection and is not the credential for any HTTP endpoint. ### 5.4 Notification rendering -`send_notification` (`channel.rs`) is the only function that renders a notification into +`send_notification` (`src/channel.rs`) is the only function that renders a notification into Telegram text, and it renders exactly three metadata lines — never the body, and never an LLM summary: @@ -494,10 +497,10 @@ in the SPA and applied hot, with no restart. | Parameter | Default | Range / cap | Source | |---|---|---|---| -| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default state.rs:56; validated notify.rs:541 | -| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default state.rs:57; validated notify.rs:542 | -| `llm_timeout_ms` | 30_000 | 100..=300_000 | default state.rs:58; validated notify.rs:543 | -| `max_retries` | 3 | hard cap 5 | default state.rs:59; rejected at notify.rs:544; re-clamped at channel.rs:138 | +| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default in `state.rs`; validated in the write path | +| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default in `state.rs`; validated in the write path | +| `llm_timeout_ms` | 30_000 | 100..=300_000 | default in `state.rs`; validated in the write path | +| `max_retries` | 3 | hard cap 5 | default in `state.rs`; rejected in the write path; re-clamped in `channel.rs` | There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in `config:outbound` and is bounded at 5 regardless of what is written. @@ -506,24 +509,24 @@ There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in | Constant | Value | Source | |---|---|---| -| `MAX_BASELINE_PAGES` | 100 | worker.rs:249 | -| `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:250 | -| `RECONCILE_BUDGET` | 20 s | worker.rs:251 | -| `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:307 | -| Lock TTL / heartbeat | 300 s / 90 s | notify.rs:289 / 302 | +| `MAX_BASELINE_PAGES` | 100 | `worker.rs` | +| `MAX_BASELINE_EMAILS` | 10_000 | `worker.rs` | +| `RECONCILE_BUDGET` | 20 s | `worker.rs` | +| `CHANGE_WINDOW_CAP` | 4_096 | `worker.rs` | +| Lock TTL / heartbeat | 300 s / 90 s | `reconcile` and its heartbeat task | `max_changes` is a function parameter, not a constant. ### 6.3 Idle threshold -XAUTOCLAIM's idle threshold is derived from the live outbound config (state.rs:99): +XAUTOCLAIM's idle threshold is derived from the live outbound config: ``` count × (max_retries + 1) × (jmap_timeout_ms + telegram_timeout_ms + llm_timeout_ms) × 2 ``` -floored at the legacy 300 s single-event ceiling (state.rs:66) and capped at 6 h -(state.rs:76). The floor is also the fallback when the config cannot be read, so a +Floored at the legacy 300 s single-event ceiling and capped at 6 h. The floor is also the +fallback when the config cannot be read, so a Redis hiccup never collapses the window. Only duplicate-on-multi-instance is at stake, never loss. @@ -531,9 +534,9 @@ stake, never loss. | Constant | Value | Source | |---|---|---| -| `SEARCH_LIMIT` | 10 | worker.rs:619 | -| `SEARCH_SUBJECT_MAX` | 120 chars | worker.rs:621 | -| `SEARCH_PREVIEW_MAX` | 160 chars | worker.rs:623 | +| `SEARCH_LIMIT` | 10 | `worker.rs` | +| `SEARCH_SUBJECT_MAX` | 120 chars | `worker.rs` | +| `SEARCH_PREVIEW_MAX` | 160 chars | `worker.rs` | --- @@ -541,21 +544,21 @@ stake, never loss. Served from `src/web.rs`. -- Three routes only: `GET /`, `GET /assets/config.js`, `GET /assets/styles.css` (web.rs:21-23). +- Three routes only: `GET /`, `GET /assets/config.js`, `GET /assets/styles.css`. Everything else returns 404. -- Assets are embedded at compile time with `include_str!` (web.rs:15-17). There is no +- Assets are embedded at compile time with `include_str!`. There is no filesystem access at runtime. - Every response carries `Content-Type`, `Cache-Control: no-store`, - `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` (web.rs:43-51). -- CSP is applied **only to the HTML document**: the constant is at web.rs:39, inserted inside - an `if html` block (web.rs:53-58): + `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. +- CSP is applied **only to the HTML document**: inserted inside + an `if html` block: ``` default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none' ``` -- `X-Frame-Options: DENY` is set alongside the CSP, on the HTML response only (web.rs:58). +- `X-Frame-Options: DENY` is set alongside the CSP, on the HTML response only. `default-src 'none'` is deliberately stricter than `'self'`: the page is closed by default and only `script-src`, `style-src` and `connect-src` re-open a same-origin channel. @@ -566,8 +569,8 @@ and only `script-src`, `style-src` and `connect-src` re-open a same-origin chann Recorded here so that references elsewhere cannot be mistaken for shipped features. -- `/search` **is** implemented (`bfe0fd8`). Adapter: `Intent::Search` at `worker.rs:553` and - at `src/domain/jmap.rs:268`, backed by `search_emails` at `src/domain/jmap/client.rs:320`. +- `/search` **is** implemented (`bfe0fd8`). Adapter: `Intent::Search` in `worker.rs` and + in `src/domain/jmap.rs`, backed by `search_emails` in `src/domain/jmap/client.rs`. What is *not* possible: **body-level** snippets. jmap-client `0.4.2` only exposes `emailId`/`subject`/`preview` from `SearchSnippet/get`, and its `Filter` type has no comparator syntax, so per-part body highlight cannot be modelled through the locked crate. Search degrades to diff --git a/docs/retired.md b/docs/retired.md index e15cefe..29dc753 100644 --- a/docs/retired.md +++ b/docs/retired.md @@ -75,8 +75,8 @@ |---|---|---|---| | 把 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:718`) | -| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:898-901`) | +| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1094`) | +| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1274-1278`) | | `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:381`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:385`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | diff --git a/scripts/docs_check/audit_anchor_semantics.py b/scripts/docs_check/audit_anchor_semantics.py new file mode 100755 index 0000000..0408267 --- /dev/null +++ b/scripts/docs_check/audit_anchor_semantics.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Audit whether a docs anchor actually sits next to the identifier it claims. + +`audit_anchors.py` proves only that an anchor targets a real, non-blank line. +It cannot tell you that the doc says "`refresh_business_config` at +`notify.rs:381`" when line 381 is `async fn worker`. This script asks that +weaker follow-up question: does the identifier the doc names next to an anchor +appear anywhere near the anchored line? + +If it does not, the anchor is a *candidate* for being wrong -- not a verdict. +Several shapes are legal on purpose, so this script never fails the gate: +it prints an INFO-level candidate list, always exits 0, and is deliberately +not wired into `run_all.sh`. + +Intended use: after a batch of anchor re-pointings, re-run this and compare the +count. A shrinking list means the anchors are landing where the docs say; a +flat list means something is still off. It is the only automated substitute for +reading every anchor by hand, which is why it is worth existing. + +How an identifier is matched to an anchor: docs pair them in prose order +("key built at `foo.rs:424`, `claim_dedup` at `foo.rs:458`"), so an identifier +claims the *next* anchor after it, never the nearest one. On a table row +carrying several anchors the preceding anchor sits just as close on the wrong +side, and pairing by distance reports every such row as drift. + +Known-legal anchors -- do not "fix" these: + - the doc anchors a call site while the definition lives elsewhere + - the identifier is on an adjacent line the anchor does not name + - the "identifier" is a value, e.g. an env var name such as `DEBUG_TOKEN` + - the identifier is a data literal the doc deliberately points at (a Lua + script body, a JSON string) +The shape filter drops the third shape (no underscore); the LEGAL table drops +the rest. LEGAL is keyed on the anchor target's span, not the doc line, because +re-pointing the docs moves the doc line while the target does not. + +Constants ROOT / DOCS / FILE / EXT / REF / resolve are copied verbatim from +`audit_anchors.py` so this script stays standalone. `audit_anchors.py` cannot +be imported: its module-level code runs the audit and calls `sys.exit`. Keep +the copies in sync if the anchor grammar changes. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', + 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', + 'docs/opengaps.md', 'docs/charter.md', 'cloudflare-worker/README.md'] + +FILE = r'((?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_-]+)' +EXT = r'\.(rs|js):(\d+)' +REF = re.compile( + r'((?:src|cloudflare-worker/src|web)(?:/[A-Za-z0-9_.-]+)*/)?' + + FILE + EXT + r'(?:\s*[,–\-]\s*(\d+))?' +) + +# Lowercase snake_case only: `refresh_business_config`, `retry_or_dlq`. +# Requiring at least one underscore is the main noise filter -- it drops bare +# words like `worker` or `batch` that a doc line mentions incidentally, and it +# drops UPPER_SNAKE env names like `DEBUG_TOKEN`, which are values, not Rust +# identifiers. Length >= 4 is enforced separately. +IDENTIFIER = re.compile(r'`([a-z][a-z0-9]*(?:_[a-z0-9]+)+)`') + +# Generic accessors too common to be meaningful evidence either way. The first +# four are already excluded by the shape filter; they are listed explicitly so +# the intent stays visible if the filter is ever loosened. +GENERIC = frozenset(['none', 'ok', 'err', 'some', + 'is_none', 'is_some', 'is_ok', 'is_err', 'to_string']) + +WINDOW = 2 # lines above and below the anchor target +MIN_LEN = 4 # minimum identifier length +NEIGHBOURHOOD = 60 # max chars from an identifier to the anchor it claims + +# Anchors that are correct as written but sit outside the +/- WINDOW purely by +# design. Keyed on (identifier, source file) -> the span the target may fall in. +# Not keyed on the doc line: re-pointing the docs moves the doc line while the +# target these anchors claim does not. `None` -> main.rs:135 and +# `DEBUG_TOKEN` -> main.rs:106 belong to the same class but are already dropped +# by the shape filter (no underscore), so they need no entry here. +LEGAL = { + ('refresh_business_config', 'notify.rs'): (381, 381), + ('invalid_request', 'notify.rs'): (402, 402), + ('reconcile_token', 'notify.rs'): (284, 284), + ('worker_token', 'notify.rs'): (393, 393), + # retry_or_dlq spans state.rs:472-503, its Lua script body 484-491. A doc + # may anchor anywhere inside and never mention the identifier. + ('retry_or_dlq', 'state.rs'): (472, 503), +} + + +def resolve(prefix, base, ext): + """Map an anchor's path fragment to a file on disk, or None.""" + if prefix is None: + if ext == 'rs': + candidates = [ROOT / 'src' / f'{base}.{ext}'] + else: + candidates = [ROOT / 'cloudflare-worker/src' / f'{base}.{ext}', + ROOT / 'web' / f'{base}.{ext}'] + else: + candidates = [ROOT / prefix.rstrip('/') / f'{base}.{ext}'] + return next((c for c in candidates if c.is_file()), None) + + +def window_text(src, line): + """The anchor's target line plus WINDOW lines each side.""" + lo = max(0, line - 1 - WINDOW) + return '\n'.join(src[lo:line + WINDOW]) + + +def gap(left, right): + """Chars of text between two non-overlapping spans.""" + return max(left.start() - right.end(), right.start() - left.end(), 0) + + +def identifiers_near(line): + """Backticked snake_case identifiers on the doc line worth checking.""" + return [m for m in IDENTIFIER.finditer(line) + if len(m.group(1)) >= MIN_LEN and m.group(1) not in GENERIC] + + +def partner_for(im, anchors): + """The anchor an identifier claims. + + Prose pairs them in document order -- "key built at `foo.rs:424`, + `claim_dedup` at `foo.rs:458`" -- so an identifier belongs to the next + anchor after it. Nearest-anchor pairing mis-reads that line: the + preceding anchor sits two characters closer and swallows the claim. + Falls back to the previous anchor when the identifier trails the line. + """ + following = [a for a in anchors if a.start() >= im.end()] + if following: + return following[0] + preceding = [a for a in anchors if a.end() < im.start()] + return preceding[-1] if preceding else None + + +candidates = [] +seen = set() +for d in DOCS: + lines = (ROOT / d).read_text().splitlines() + in_fence = False + for i, line in enumerate(lines, 1): + if re.match(r'^\s*(```|~~~)', line): + in_fence = not in_fence + continue + if in_fence: + continue + anchors = list(REF.finditer(line)) + if not anchors: + continue + for im in identifiers_near(line): + best = partner_for(im, anchors) + if best is None or gap(im, best) > NEIGHBOURHOOD: + continue + prefix, base, ext, a, b = best.groups() + p = resolve(prefix, base, ext) + if p is None: + continue + src = p.read_text().splitlines() + lo, hi = int(a), int(b) if b else int(a) + if hi > len(src): + continue + tok = im.group(1) + key = (d, i, tok) + if key in seen: + continue + seen.add(key) + span = LEGAL.get((tok, p.name)) + if span and span[0] <= lo <= span[1]: + continue # correct as written; the gap is intentional + if any(tok in window_text(src, n) for n in {lo, hi}): + continue # identifier is on the anchored line (+/- 2) + candidates.append( + f'{d}:{i} `{tok}` -> {p.relative_to(ROOT)}:{lo}' + + (f'-{hi}' if b else '') + + f' | {src[lo - 1].strip()}' + ) + +print(f'=== ANCHOR SEMANTICS ({len(candidates)} candidates) ===') +for c in candidates: + print(c) +sys.exit(0)