Commit Graph

129 Commits

Author SHA1 Message Date
anonsaber baa6d8a6a1 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.
2026-10-01 11:35:06 +08:00
anonsaber 2089a95b79 docs: drop the SPA-footer deploy proof and reanchor notify.rs refs
Four spots told the operator to read the SPA footer to confirm a
deploy landed. The footer no longer renders a version, so restating
that would send an operator looking at a control that no longer
exists. Point them at /api/status instead; the version field itself is
unchanged.

The previous commit inserted 500+ lines into notify.rs, moving
register_push and register_telegram_webhook. Reanchor the two
deployment.md line refs; the other seven refs in that file are before
the insertion point and are unchanged.

Document the new retire-on-change contract in reference.md.
2026-10-01 11:18:07 +08:00
anonsaber 0b27ca03a6 ui: drop the build-version display and fix the callback card
The value rendered was nogit+<UTC build time>, which carried no
information for the operator, so remove all three display sites
(topbar next to the secure-channel note, setup card, page footer).
Clears the three querySelectors, renderVersion, and the two dead i18n
keys. /api/status is untouched and still returns version - the API is
now the only way to confirm a deploy landed.

The <footer> element is removed with its span rather than left empty:
it had no other child, and an empty footer would still render a
visible separator band (border-top plus 41px padding).

The callback section's field was the only one in the SPA sitting
directly on the panel, with no padded ancestor. It spanned the full
card width with zero side padding and pressed the button bar, while
the copy above it was indented 25px. Wrap it in .field-grid and pad it
to match its card siblings on both desktop and the 720px breakpoint;
.field-wide's grid-column now has a grid ancestor.

Rewrite the callback copy to name the Cloudflare Worker front proxy
explicitly instead of the bare word Worker, and state that registering
a new address retires the previous subscription.
2026-10-01 11:17:50 +08:00
anonsaber b3df7c5d87 feat(push): retire the previous subscription when the callback URL changes
Registering with a new callback_url previously left the old Stalwart
subscription live alongside the new one, so Stalwart pushed the same mail
event to both origins and the user got duplicate notifications. The
per-URL mapping had no record of which subscription was current.

Add a push:registration:current pointer written alongside the per-URL
mapping. register_push now retires the previous subscription under the
existing registration lock before creating the new one: destroy first,
then delete the mapping, then mark the status disabled. A destroy failure
returns a retryable 503 (push_destroy_failed) rather than proceeding
silently - silently skipping is exactly the shape of the original bug.
The idempotency short-circuit still runs first, so re-registering the
same URL never touches the retire path. disable_push clears the pointer
only when the disabled URL is the current one.

Extracts destroy_push_registration, shared by disable_push and the new
retire path. Telegram needs no change: setWebhook replaces the previous
webhook atomically.

Adds 5 tests with a recording mock that captures the create/destroy
call order: idempotent no-recreate, retire-on-change, abort-without-
create when the old destroy fails, skip-destroy when the previous
mapping expired, and pointer untouched when disabling a non-current URL.
2026-10-01 11:17:32 +08:00
anonsaber 0c84f8ef56 ui: simplify footer to build version 2026-10-01 09:23:33 +08:00
anonsaber af89a17f7f feat(auth): extend admin session to 30 minutes 2026-10-01 09:19:49 +08:00
anonsaber b410dc4257 ui: trim Chinese admin copy 2026-10-01 09:11:23 +08:00
anonsaber 2752069eb3 docs: fix Chinese technical terminology 2026-10-01 03:33:42 +08:00
anonsaber e4a0a4b7db docs: translate Chinese document headings 2026-10-01 03:21:57 +08:00
anonsaber 1d4959a855 feat(worker): redirect HTTP to HTTPS 2026-10-01 02:52:01 +08:00
anonsaber 94032a1e08 docs: right-size test guidance and Redis wording 2026-10-01 02:38:16 +08:00
anonsaber 7a27f7ff00 docs: add bilingual guides and callback setup in SPA 2026-10-01 02:36:03 +08:00
anonsaber 98d62e490c docs(spa): 登录卡文案去重,只改字符串不动结构与逻辑
授权卡里同一件事被讲了三遍:CONFIG_ENCRYPTION_KEY 在正文、占位符、
顶部横幅各出现一次;「不写 localStorage」在提示里说、在 setup 隐私说明里
又说一遍;「只在页面内存」在正文、提示、会话过期默认文案里各出现一次。

收敛到每句话只出现一处(正文讲动作与 TTL,占位符只提示粘贴,提示只讲
重新授权,存储与内存的事实保留在正文与 setup 隐私说明里):

- auth.bodyPre   输入启动必填的环境变量 -> 输入
- auth.bodyPost  服务端会签发有效期 15 分钟的管理会话;该值与会话只在当前页面内存中使用
                -> 管理会话有效期 15 分钟,只存在当前页面内存中
- auth.placeholder 输入 CONFIG_ENCRYPTION_KEY 的值 -> 粘贴该值
- auth.hint      不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权
                -> 关闭或刷新页面后需要重新授权

EN 字典同步,web/index.html 里 3 处 data-i18n 回退文案跟着改。
键名、DOM 结构、data-i18n 键、逻辑与校验全部未动。
node --check web/config.js 通过;node --test web/config.test.mjs 6/6 通过。
2026-09-30 23:35:32 +08:00
anonsaber f9889288ba docs: 同步 /worker 进 safelist(17 -> 18 条),cron 示例收敛到单一域名
- deployment.md:§6.3.1 两条端点都可直接打 Worker 域名,手动 curl 与 crontab、
  k8s CronJob 示例去掉 WORKER_ORIGIN 只留 APP_URL,-m 分别给 320s / 300s(要略大于
  LB 侧 per-route 超时,否则 curl 先掐掉一个本来能完成的排空);§10.4 safelist
  18 条并补 /worker、不在 safelist 的从 3 类减为 2 类;LB_WORKER_TIMEOUT_MS 补进
  secret put 清单与 §10.9 变量表;测试计数 35 -> 37(index 12 -> 14)
- README.md:路由白名单 17 -> 18 条、/worker 入列、LB_WORKER_TIMEOUT_MS 补进
  变量表与配置片段、/reconcile 例外段落扩成 /reconcile + /worker 两条长超时
- charter.md / reference.md:17 -> 18 条、Safe Routes 16 -> 18 entries
- cron-drain.sh:MW_WORKER_URL 由「必须指源站直连地址」改为「可选,默认走平台域名」;
  404 route not forwarded 的提示改为 LB 版本太旧(需 >= 2026.10.3),升级或绕过 LB

顺手修正 6 处已漂移的行号锚点(此前指向空行或已迁移的文件):
backends.js:10-28 -> 10-30、index.js:47-65 -> 54-73、index.js:76-78 -> 87-91、
index.js:87-89 -> 95-96、index.js:93 -> 105、src/index.js:138 -> src/lb.js:155
(x-lb-backend 的 catch 在 656034d 修不可变响应头时已迁到 lb.js,文档一直指旧文件)。
GATE-DOCS PASS(115 line-refs / 477 path-refs / 252 section-refs,0 error)。
2026-09-30 23:08:57 +08:00
anonsaber 6bb57801ac feat(lb): POST /worker 加入路由 safelist,走独立长超时且不故障转移
外部 cron 触发排空队列时,/worker 走 Worker 域名会被 404 route not forwarded
挡掉,只能直连后端 origin;而 /reconcile 早就能走 Worker。cron 脚本因此被迫
维护两个域名。现把 /worker 与 /reconcile 成对放进 safelist(17 -> 18 条),
cron 只需一个平台域名。

同时给它配独立超时:全局 LB_REQUEST_TIMEOUT_MS 默认 10s 会把一次正常的排空
误判成失败,而 maxAttempts=2 切到第二实例毫无意义——第二个实例只会重复排
同一批消息。故新增 LB_WORKER_TIMEOUT_MS(默认 300000ms,等于后端单条事件
下界 state.rs SINGLE_EVENT_CEILING_FLOOR_MS),并强制 maxAttempts=1。

- SAFE_ROUTES 加 "/worker";ROUTE_METHODS 加 "/worker": ["POST"](GET => 405)
- 新增 DEFAULT_WORKER_TIMEOUT_MS = 300_000 与 per-route 覆盖分支
- LB_VERSION 2026.10.2 -> 2026.10.3(改 LB 逻辑须 bump)
- test/index.test.js 新增回归:/worker maxAttempts=1、默认 300s、env 覆盖、
  GET /worker => 405 且不打到后端
- test/backends.test.js safelist 用例补 /worker 与 /api/business-config/preflight

node --test:37 通过 / 0 失败(index 14 / backends 6 / health 5 / lb 12)。
2026-09-30 23:08:41 +08:00
anonsaber fae37b35bc chore(ops): scripts/cron-drain.sh 加可执行位,便于直接 ./ 调用 2026-09-30 22:29:45 +08:00
anonsaber 3917b96e71 docs: §4.1 / §6.3.1 指向 scripts/cron-drain.sh
「为什么收不到 Telegram」的答案分两层:调度没跑(外部 cron 问题),还是配置或
凭据问题。§4.1 第三步和 §6.3.1 各加一段指向新脚本,并说明 --diagnose 与
--test-notify 两个开关分别用来区分这两种情况。

顺带核对过 §6.3.1 的状态码表与 src/notify.rs 实际契约一致:/reconcile 成功是
204(401/409/503+Retry-After),/worker 成功是 204(400/401/503),RECONCILE_
INITIAL_CHANGES=100 在 notify.rs:294,/worker batch 上限 MAX=10 在 notify.rs:407,
行号锚点全部准确。

GATE-DOCS 6/6(PATH 475 -> 477)。
2026-09-30 22:24:53 +08:00
anonsaber b9b7114fd3 feat(ops): scripts/cron-drain.sh,外部调度脚本,参数全走环境变量
后端没有内部调度器(C-NO-LONG-CONN),消费出站队列的唯一入口是 POST /worker,
增量对账的唯一入口是 POST /reconcile,两者都必须外部触发。没有这一步,新邮件
会一直堆在 stalwart:jmap,一行通知都不会发出来。

- 一次触发做两步:先 /reconcile 再 /worker,顺序不可反
- 两个端点成功码都是 204 且响应体为空;401/409/503 各给明确提示
- 503 时把服务端的 Retry-After 一并打进日志,cron 侧不用自己猜
- MW_WORKER_URL 单独可配:/worker 不在 LB 白名单内,走 LB 会 404,脚本会点出来
- --diagnose 走 /debug/*:配置是否保存、chat_id 是否占位值、webhook secret、
  Redis/JMAP/Telegram 依赖可达性、游标是否还停在 null
- --test-notify 绕过队列复用生产出站路径直发一条:这条能收到,问题一定在调度
- 凭据只从 MW_* 环境变量读,不读 .env、不写文件,日志不含 token
- --once / --loop;MW_BATCH(服务端上限 10,超出提示)、MW_LOG_FILE、MW_TIMEOUT
- 退出码 0/1/2 二值,失败步数看日志行数
2026-09-30 22:24:41 +08:00
anonsaber 89dfe5cf3d feat(lb): /healthz 透传到源站,LB 聚合探针改挂 /healthz-worker
源站自己也暴露 /healthz;两者同路径时走 Worker 域名只能看到 LB 的聚合
结果,看不到源站自己的健康状态。现在拆开:

- GET /healthz        透传到源站(源站自己的健康检查)
- GET /healthz-worker LB 聚合探针(LB_VERSION + 后端汇总)

SAFE_ROUTES 增列 /healthz、ROUTE_METHODS 增列 /healthz: [GET],白名单 16 -> 17。
LB 探测源站的路径仍是 /healthz,不变。LB_VERSION 2026.10.1 -> 2026.10.2。

GATE-DOCS 6/6;node --check 全部通过。
2026-09-30 21:36:37 +08:00
anonsaber 15fa7f1e35 feat(lb): /healthz 回显版本号 LB_VERSION,确认线上是哪次部署在回答
上次生产事故里「改了代码不知道有没有生效」缺少直接证据;现在 /healthz 的
JSON 多一个 version 字段,取 env.LB_VERSION(写在 wrangler.toml 的 [vars],
当前 2026.10.1),未配置回退 unknown,探针永不因缺该字段而失败。

healthResponse(summary, version) 的第二参数可选:省略时 JSON 不含该字段
(JSON.stringify 丢弃 undefined),既有调用与用例不受影响。

放 [vars] 而不是 dashboard Add Variable 的理由:它必须跟代码同版本、可 diff,
写进 dashboard 反而容易漂。代价是改 LB 逻辑的提交要顺手 bump 它。

文档同步:§10.4 响应体形状加 version、§10.9 [vars] 示例与验证步骤、
README 环境变量表补一行;用例计数 32 → 35。

验证:node --test 35/35;wrangler deploy --dry-run 通过;docs gate 6/6。
2026-09-30 20:21:48 +08:00
anonsaber 656034d71d fix(lb): 可观测头写入不可变响应头不再把健康的 200 变成 503
mark() 往代理响应上写 `x-lb-backend`,但跨域且无 CORS 允许头的响应其
headers guard 是 immutable,`set` 抛 `TypeError: Can't modify immutable
headers.`;异常冒泡到 forwardWithFailover 的 catch 后被判成后端故障
(`backend ... unavailable (TypeError)`),重试耗尽返回 503。

后端一直健康:GET / 实际返回 200。/healthz 走 healthResponse() 自建
响应、不调 mark(),所以不受影响——这也正是它掩盖问题多年的原因。

mark() 现在在响应头不可变时静默放弃观测头,绝不阻塞响应返回;补两条
回归测试(2xx 不可变响应必须原样返回且不触发故障转移;5xx 不可变响应
仍正常故障转移)。

验证:wrangler dev --local 下 / /healthz /ready /api/status /assets/*
全部 200(修复前 / 与 /api/status 为 503);node --test 34/34 通过。
2026-09-30 20:02:18 +08:00
anonsaber 3d6606d0cd docs: deployment.md §10/§10.9 同步 origin 列表改为 [vars] 明文例外
- §10 部署步骤:BACKEND_ORIGINS_JSON 从 secret 注入改为 wrangler.toml 的 [vars],
  secret put 降级为「出现非公开值时」的回退路径,并写明边界。
- §10.9 预览命令说明:预览环境现在能拿到 [vars] 里的 origin(能跑通转发);
  改回 secret 注入时才会退回只能验证 Worker 拉起 + 路由表生效。
- GATE-DOCS 6/6 clean(114 anchors / 252 sec-refs / 474 path-refs)。
2026-09-30 19:07:17 +08:00
anonsaber aa78c13161 worker: BACKEND_ORIGINS_JSON 写入 wrangler.toml 的 [vars](明文例外)
Cloudflare Git 集成会提示"Update your Wrangler configuration with these changes to
keep deployments in sync",原因是 UI 上的 origin 列表没在仓库里。接受该建议:origin
列表只是公开可达的 https 地址,不是凭据,所以可以随仓库走——配置在 PR 里可审,
预览环境也能拿到它(secret 只存在于 dashboard,preview 拿不到)。

wrangler.toml 加了「明文例外的边界」评论:一旦出现内网地址 / 带内嵌凭据的 URL /
不想公开的 staging 主机名,立刻从 [vars] 删掉改用 wrangler secret put(git 历史
不可逆)。其余三个 LB_* 保持可调参数不写死。

已用实际解析器验证:parseBackendOrigins 返回 [{url: https://messageweave-eu-1.motofans.club,
healthPath: /healthz}];npm test 32 通过 0 失败;wrangler deploy --dry-run 通过,
配置里可见 env.BACKEND_ORIGINS_JSON 绑定。
2026-09-30 19:07:17 +08:00
anonsaber d7033066e2 docs: §10.9 构建命令改为 npm test,不再要求 npm ci
wrangler deploy 自己用 esbuild 把 4 个源文件打成一个 index.js 再上传
(实测 --outdir 产物 33,078 字节 + sourcemap),src/ 零外部依赖,所以
构建命令留空也不会失败——「构建」和「部署」在本项目是同一个动作。

填 npm test 的价值不在打包,而在两点:
- Cloudflare 默认安装跑 bun install,不读 npm 锁文件,已提交的锁文件
  被绕过,wrangler 版本由 ^4.144.0 浮动;
- npm test 是 32 个测试构成的部署前门禁,不依赖 node_modules(零外部
  依赖,空目录也能跑通 32/32,实测 2.6 秒)。

npm ci --no-audit --no-fund && npm test 仍写进文档作为锁死版本的选项,
代价是每次重新下载安装 35 个包(实测约 86 秒)。
2026-09-30 18:46:04 +08:00
anonsaber 7aa880c429 docs: §10.9 构建命令改为 npm ci + npm test
Cloudflare Git 集成的默认安装步骤实测用的是 bun install(日志
'Installing project dependencies: bun install'),bun 不读 npm 锁文件、
自己重新解析依赖('Saved lockfile'),仓库里提交的锁文件被完全绕过。
只填 no-op 构建命令意味着 CI 装的是 bun 解析出来的版本树,而不是我们
锁定的 wrangler 4.144.0。构建命令改成显式 npm ci 后由 npm ci 删掉
node_modules 再严格按锁文件重装;项目无编译步骤,顺带跑一遍 npm test
当门禁。

同时把 Node 版本说明改为实测值(CI 给的是 nodejs@24.18.0),并记录
wrangler 4 与 3 一样按 wrangler.toml 的 name 部署。
2026-09-30 18:08:57 +08:00
anonsaber 7737693c2b worker: wrangler 升到 4.144.0,消掉 CI 里的 out-of-date 警告
CI 实测装出 3.114.17 并打印
'The version of Wrangler you are using is now out-of-date ... to prevent
critical errors'。锁文件重新生成后 wrangler 4.144.0:

- node 24.21.0 / npm 11.19.0: npm ci 干净装出 35 个包
- npm test: 32/32 通过
- npx wrangler deploy --dry-run --outdir=.build-check: 32.30 KiB 上传,
  无配置警告、无 out-of-date 提示
2026-09-30 18:08:48 +08:00
anonsaber e853032445 docs: 修正 §10.9 预览命令的建议——它是必填项,不是留空
上一条把预览命令写成「留空并取消勾选」,但 UI 里勾选启用预览构建后该字段为必需。
预览命令改用 npx wrangler dev --ip 0.0.0.0 --port 8787(wrangler 3.114.17 无
preview 子命令,dev 才有 --ip/--port;8787 是 dev 默认端口)。

同时澄清构建命令 ≠ 部署命令:部署命令(必需)保持 npx wrangler deploy 不动,
把 deploy 塞进可选的构建命令才是错的。补一句预览环境没有 BACKEND_ORIGINS_JSON,
所以预览只能验证 Worker 能拉起与路由表生效,后端转发只能看生产。

另补:根目录在折叠的「高级设置」里,设了之后三个命令都在 cloudflare-worker/ 下执行。

GATE-DOCS 6/6(114 行号锚点 / 252 节引用 / 469 路径引用)。
2026-09-30 17:10:35 +08:00
anonsaber 8d73b73fb5 docs: 新增 §10.9 网页版部署(Dashboard Code Editor / Git 集成)
补 Cloudflare Dashboard 的 Git 集成配置对照表——仓库根目录是 Rust 后端,
必须把根目录设成 cloudflare-worker,否则 Cloudflare 在仓库根跑 wrangler 直接失败。
另外三条:应用名要与 wrangler.toml 的 name 对齐、构建命令改成 no-op
(Cloudflare 构建后自己跑 wrangler deploy)、预览命令留空并关闭预览
(wrangler 3.114.17 无 preview 子命令,且预览环境没有 BACKEND_ORIGINS_JSON)。

GATE-DOCS 6/6(114 行号锚点 / 252 节引用 / 468 路径引用)。
2026-09-30 16:49:04 +08:00
anonsaber caa78787a3 worker: 提交 package-lock.json,让 Cloudflare 的 Git 集成走 npm ci
没有 lockfile 时 Cloudflare CI 跑 npm install,wrangler ^3.57.0 是浮动范围,
每次 CI 可能解析到不同小版本,构建不可复现。锁定后 npm ci 精确复现
wrangler 3.114.17(= 本地已验证的版本)。

从干净目录端到端验证:npm ci → npm test 32/32 通过 → npm run check
(wrangler deploy --dry-run)通过,上传 31.48 KiB。
2026-09-30 16:47:27 +08:00
anonsaber 048bd5d243 docs: 部署章节标出三个会让首次上线直接失败的坑
- §10.8 的 BACKEND_ORIGINS_JSON 示例用的是 {url,weight} 对象数组,但代码只
  接受字符串数组;且配置是每个请求重新解析的,症状是「每个请求 503」而不是
  「启动即 503」。补上 parseBackendOrigin 的位置与权重不存在的说明
  (lb.js 用 rng() 均匀选起始源)。
- 新增两条实测坑:wrangler.toml 的 JSDoc 注释会让 wrangler 在读配置阶段失败
  (Unknown character 47);npm test 在 Node 22.23 上 MODULE_NOT_FOUND。
- §10.9 验证第 2 条:/healthz 是 Worker 自生成、不透传后端,且 BACKEND_ORIGINS_JSON
  缺失/格式错/为空时 handleHealth 吞掉解析异常返回 HTTP 200 + no-backends,
  所以只看 200 会误判,必须看响应体的 available。补上三档判据速查。
2026-09-30 16:24:44 +08:00
anonsaber 826b41e781 worker: 修掉两个会让 Worker 部署直接失败的配置错误
- wrangler.toml 用 JSDoc 的 /** */ 注释,TOML 解析器把 '*' 当未知字符
  (wrangler 报 Unknown character 47),deploy/secret put/dev 在读取配置阶段
  就失败,根本走不到你的 secret。全部展开成 '#' 行。
- npm test 写成 node --test test/,Node 22.23 会把 test/ 当模块路径去
  require,报 MODULE_NOT_FOUND。改成 node --test(无参数自动发现 test/),
  仍为 32 个用例全通过(backends 6 / health 4 / index 12 / lb 10)。

wrangler deploy --dry-run 现通过(wrangler 3.114.17,上传 31.48 KiB)。
2026-09-30 16:24:32 +08:00
anonsaber baff2dde8d docs: 关闭最后一个缺口(多账户不做),清掉由此暴露的 5 处陈旧指向 2026-09-30 15:12:53 +08:00
anonsaber 71336ea6d3 docs: record the real-Redis TTL green light and drop the credential ask
- opengaps: stage goal 2 is fully closed, the blocker list is empty, and the
  remaining user action is only the multi-account product decision.
- opengaps: record the fourth self-inflicted test bug -- the flaky 1s TTL
  window -- and what the corrected assertion actually proves, including the
  B-class Lua EXPIRE value now verified on a real instance.
- design: the stage-0 paragraph still claimed 69 passed / 1 ignored, which
  contradicted charter TEST_BASELINE; point it at the authoritative line.
- No credential values anywhere; the URL is written as a placeholder.
2026-09-30 14:52:06 +08:00
anonsaber 5c77fab597 test(state): assert the dedup TTL floor as an upper bound, not a window
A remote TLS readback arrives after most of the 1s floor has been consumed,
so the +/-500 ms window could never be met (PTTL -2 / 269 / 118 ms across
three runs). PTTL -1 is what actually signals the regression this assertion
guards against -- EX silently dropped, key lives forever -- and a live value
can only be counting down, so bound it from above.

SET ... NX EX 1 itself is unchanged; the expired (-2) outcome is what proved
it took effect.
2026-09-30 14:51:58 +08:00
anonsaber e9c979bd1a docs: align the oldState wording with the domain doc 2026-09-30 14:14:37 +08:00
anonsaber 2447106840 docs: describe the reconcile cursor, re-anchor drifted refs, fix three stale claims
reference.md: new Section 6.5 explains the bimodal cursor, the two token
classes (Session.state is not an Email-collection token; newState is), why a
truncated walk persists as a walk, and why oldState and destroyed are dropped.
Section 6.2 budget table is renamed to the new constant names and gains
RECONCILE_INITIAL_CHANGES. state:jmap:since is listed as a pipeline key.

opengaps.md: the re-baseline fix is described with the new session_state()
name, and the position-walk note no longer claims a state argument is passed
as None - the None is folder_id, and the method has no state parameter at all.

retired.md: the dead-code removal no longer claims those were the last three
#[expect(dead_code)] attributes in src/; reload_async still has one.

deployment.md / design.md: the 54+14 anchors the code move had shifted by one
hunk are re-anchored, plus two manual corrections (the /reconcile route and the
unconditional 204 return). charter.md: baseline 89 -> 93 passed.
AGENTS.md: the two anchor counts it quotes (60, 44) were stale; they are 54
and 17.

check_sec_refs.py: two-part section refs attributed to an RFC (RFC 8620
Section 2.1) are external, not doc references - the trailing-dot test only
caught the three-part form.
2026-09-30 14:11:52 +08:00
anonsaber ac27e593dd refactor(worker): make the reconcile cursor an explicit mode instead of a sentinel
The persisted cursor was a bare (String, usize) tuple in which usize::MAX meant
"already in replay mode". A real walk position of usize::MAX would have decoded
as a bare state token and its walk would have been skipped silently.
ReconcileCursor now carries the two modes, and the pass is split along the same
boundary: reconcile -> walk_baseline (WalkOutcome: ReplayFrom / Persist /
NoProgress) -> replay_changes.

Behaviour is unchanged; the four walk/replay budget constants move out of the
function body to module level, and the /changes parameter is renamed
max_changes -> initial_changes because it only sizes the first call.

domain/jmap: current_state -> session_state (RFC 8620 Section 2.1, which is not
the Email-collection token that Email/changes wants as sinceState) and
has_more -> has_more_changes (hasMoreChanges). EmailChanges and both trait
methods now carry RFC-referenced docs that spell out why oldState and destroyed
are dropped at the boundary.
2026-09-30 14:11:31 +08:00
anonsaber 655167e862 docs: close the TTL gap, re-anchor drifted code refs, add anchor tooling
opengaps.md: the TTL item is now closed on the code side. The 21-value
table is keyed by ttl constant rather than by line number, so it stops
drifting. The honest caveat is called out in the open instead of being
implied away: the two real-Redis assertions are wired but unrunnable with
the credentials on hand, so 'assertion ready' is not the same as 'verified
on real hardware'. The residual-risk note that was still sitting under the
closed item has been removed along with it.

charter.md: test baseline 88 -> 89 passed.

deployment.md, design.md, retired.md: 41 stale src/ line anchors. The
drift was real and the audit missed it every time, because
audit_anchors.py only asserts that a referenced line exists and is
non-blank. Three are off-by-one; the rest are wrong code --
state.rs:379 pointed at XREADGROUP XACK while the claim was about
read_batch, retired.md:99 pointed into Intent::Search::parse, the two
route-table anchors landed on state: Arc::new(state),, and one
register_push anchor sat 31 lines above the fn.

scripts/docs_check/reanchor.py: maps HEAD -> working-tree line numbers
straight off git diff -U0 hunks, applies the fix or reports only with
--check. scripts/docs_check/show_anchors.py: prints every doc anchor next
to the line it lands on, which is what caught the semantic errors the
audit structurally cannot.
2026-09-30 13:20:13 +08:00
anonsaber e85863e6da refactor: centralize every TTL value in state::ttl and pin it
Bare TTL literals at call sites were invisible to the test suite:
MemoryState ignores the TTL argument entirely, so shrinking a 24h dedup
window to 60_480 compiled and stayed green. All 17 values now live in
pub(crate) mod ttl, the call sites reference them, and
ttl_contract_is_pinned asserts each value plus two ordering invariants
(heartbeat shorter than the lock it renews; consent tiers strictly
increasing).

The one value the earlier sweep could not reach is now reached: the EXPIRE
inside retry_or_dlq's Lua script is built with an interpolated
ttl::RETRY_COUNTER_SECONDS, so the script carries no second literal.

Gate: fmt/check/clippy clean; cargo test --locked 89 passed / 0 failed /
4 ignored (the ignored ones need REDIS_TEST_URL or JMAP_TEST_URL).

Not claimed: the two real-Redis PTTL assertions are wired but cannot run
today. Both Upstash tokens I have are rejected with -WRONGPASS, so
connect() fails before any PTTL is read.
2026-09-30 13:20:00 +08:00
anonsaber d8e1226c76 test: make the real-Redis TTL tests runnable against a rediss:// URL
The two REDIS_TEST_URL-gated tests could never have passed as written: a
unit-test binary never runs main(), so no rustls crypto provider was
installed and the TLS handshake to a rediss:// URL panicked before any
command was sent. Install the provider in a helper inside the test module
and reuse the same call main.rs already makes.

Also fix a key-construction typo in the retry/DLQ test: the retry counter
key embeds the message id, not the run id.

Verified against the live Upstash instance:
  REDIS_TEST_URL=rediss://... cargo test --locked -- --ignored ttl
  ttl_claim_dedup_sets_the_exactly_requested_expiry ... ok
  ttl_consent_and_retry_landing_on_real_redis ... ok
2026-09-30 12:45:20 +08:00
anonsaber 7d6ea013b4 fix: make the real-Redis TTL test safe to point at a shared database
Two latent bugs surfaced while preparing to run these against Upstash:

1. The retry key was mis-constructed.  builds its counter
   as , so the test had to read
   . It read
   , a key the call never writes, so the
   assertion would have failed on the first real run (PTTL of a missing
   key is -2, and the test's own  guard rejects it).
   Derive the key from the same stream name and message id the call
   receives instead of re-spelling it by hand.

2. The consent probe used the process id as a Telegram chat_id. A
   private Telegram chat_id is a plain positive integer, so a small pid
   can legitimately be a real user. If the test is pointed at the live
   database it would have overwritten that user's AI consent flag with a
   1h expiry. Anchor at -4e18 + pid instead: real Telegram ids stay
   under ~1e11 in magnitude, so no live flag can be shadowed.

All test keys remain prefixed and are deleted at the end of each test.
2026-09-30 12:39:21 +08:00
anonsaber 0890eb18f0 test: assert the three Redis TTL write points on a real Redis (opt-in)
real_redis_ttl_tests in src/state.rs closes the "no test asserts any TTL
value" gap for the three atomic write points that actually reach Redis:

- set_nx_ex  SET NX EX, with .max(1) floor (Redis rejects a zero EX)
- retry_or_dlq EXPIRE KEYS[1] 86400, embedded in the Lua script
- set_ai_consent SET EX

Each is opt-in via REDIS_TEST_URL (#[ignore], mirrors the JMAP gating in
src/domain/jmap/client.rs:522); missing the env var prints 'skipped' and
returns, so CI stays green without credentials and the URL is never logged.
retry_or_dlq uses max_attempts=99 so the script returns before XADD/XACK and
no stream or group needs to be created.

opengaps.md item 2 is now blocked only on a reachable Redis URL. Two
defects in that section were fixed while touching it: the consent table
rendered 7 * 86_400 as 604_800 and 365 * 86_400 as 31_536_000 (arithmetic
right, literal wrong) and listed 300s/7200s values that do not exist in
the code - the sixth real consent TTL is 0 (revoke). 16 line anchors had
also drifted onto comments and neighbours; all are re-pointed. The line-
range audit passes 138 refs but only checks that a line is non-blank, not
that it holds the claimed content, which is how these slipped through.

GATE-P0 baseline moves to 88 passed / 0 failed / 4 ignored.
2026-09-30 12:30:57 +08:00
anonsaber e130cb1dfb refactor: drop the never-read EmailChanges.destroyed field
It was populated at the client boundary but read nowhere in production -
the doc comment claimed "retained for cursor accounting", yet the cursor
is just new_state. Deletions are dropped at the boundary instead of being
plumbed through and then ignored one layer in.

The upstream crate still returns destroyed; we simply stop carrying it.
Re-add it only when something acts on deletions (e.g. pruning a dedup
key) - keeping an unread Vec costs a copy on every changes page for
nothing.
2026-09-30 12:20:03 +08:00
anonsaber bc68c6b241 docs: unblock GATE-DOCS (debug.rs anchors drifted + file over 500 lines)
debug.rs grew past 500 lines with no SPLIT-EVAL marker, and the seven
/src/debug/* anchors in deployment.md pointed at the blank line just
above each function - so GATE-DOCS was red on two unrelated pre-existing
counts (SIZE CHECK, ANCHOR AUDIT) while the other four checks were green.

Re-anchor all seven to the function bodies they describe, add the marker
with the reason we are keeping the module whole, and document the
redis_failure_detail classification that deployment.md still called
"redis_probe_failed". Bump the GATE-P0 baseline to 88.
2026-09-30 12:17:04 +08:00
anonsaber ae7b0293ab docs: close opengaps item 1 (re-baseline handled in code)
Item 1 asked for a downtime window to observe the newState semantics, but
jmap-client 0.4.2 exposes no reset/re-baseline signal field, so the only
observable outcome is a plain Err - the window would have proven nothing.

The real behavior was worse than the item described: Err propagated as
Err(()) with reconcile:state left untouched, so every cron retried the
same dead sinceState and reconcile answered 503 forever with no forward
progress. Fix landed in 269c8f6.

Only item 2 (TTL measurement) remains, and it needs nothing from the user.
2026-09-30 11:53:24 +08:00
anonsaber 269c8f6304 fix: re-baseline reconcile when Email/changes no longer replays
A stale sinceState made every later run retry the same dead state and
reconcile:state froze forever, so reconcile answered 503 with no forward
progress. Re-baseline to a freshly fetched state at position 0 instead of
failing: the position walk never depends on server-side changelog
retention, and the 24h dedup key keeps the re-read at-most-duplicate.

Closes opengaps.md item 1 without a downtime window.
2026-09-30 11:51:44 +08:00
anonsaber 4cff3a7cee fix: bound Redis connect retries so an unreachable host fails fast
The previous commit switched to ConnectionManager but took its config
defaults. Two of them are unusable.

Default number_of_retries is 6, which is not a bounded retry in redis
0.27.6. Measured with a throwaway binary against a refusing host
(127.0.0.1:1, ConnectionManager::new_with_config):

    retries = 0  -> returns in ~0.5 ms with IoError
    retries = 1  -> returns in ~1.5 s with IoError
    retries = 6  -> never returns (still hung at 300 s)

So the crate default is an infinite loop, not "six tries". With it in
place a Redis host that is down at boot makes startup hang forever: no
error, no log, and a container that reports Up while serving nothing.
The code path this replaced (get_multiplexed_async_connection) returned
in about half a millisecond, so the default quietly turned a fail-fast
boot into a silent hang.

Default connection_timeout is None, which leaves each attempt at the OS
connect timeout: roughly two minutes for a host that drops packets
instead of refusing them.

Setting retries to 1 restores fail-fast boot -- an unreachable host now
exits in under two seconds with `State(Redis(Connection refused))` --
while still absorbing a single transient refusal. Anything more is
redundant here: the manager re-enters the connect path on every
subsequent command once the connection drops, so recovery never depended
on one operation retrying many times.

Measured end-to-end against a local redis container, killing the Redis
and restarting it while the app stayed up:

    before (retries 6): 61.6 s to recover, and during the outage
                        /debug/redis hung past a 25 s curl timeout
    after  (retries 1):  3.0 s to recover, and /debug/redis answers
                        immediately with detail=io_error

Auto-recovery still works with zero restarts; this only makes it faster
and stops the health endpoint from wedging while Redis is down.

Both values are named constants with the measurements in their docs so
the numbers can be checked and the "why only one retry" question does
not have to be answered again.

Gates: fmt clean, clippy -D warnings clean, cargo test 87 passed,
2 ignored, unchanged from the previous baseline.
2026-09-30 10:21:16 +08:00
anonsaber 1353745f58 fix: let the Redis connection reconnect instead of dying with the process
RedisState built one MultiplexedConnection at startup and kept it for the
life of the process; every call cloned the handle, which clones the handle
and not the socket. When the underlying TCP connection dropped -- an Upstash
idle timeout, a network blip, an idle-connection cutoff -- the handle was
permanently dead. The process kept running, every Redis read failed, and
only a restart recovered it. That is what had to be restarted.

The crate says so in its own docs: redis 0.27.6 lib.rs:71 recommends
ConnectionManager "for automatic reconnections" behind the
connection-manager feature, which we had never enabled.

RedisState now holds a ConnectionManager built from the Client. Both types
derive Clone and implement ConnectionLike, so the five query_async call
sites are untouched; the three helper signatures that took a cloned
connection by value or by mutable reference change type only.
MultiplexedConnection no longer appears anywhere in src/.

Start-up semantics are unchanged: ConnectionManager::new awaits
new_connection before returning, so an unreachable Redis still fails closed
at boot rather than succeeding and erroring on first use. Reconnect is gated
on is_io_error(), which is exactly kind() == IoError. A dropped socket is an
IoError, so it reconnects with exponential backoff and jitter (base 2,
100ms factor, 6 retries); an authentication failure is AuthenticationFailed
and is deliberately not retried, because that error does not fix itself.

No Drop or cancel is needed: the live connection sits behind
Arc<ArcSwap<SharedRedisFuture>>, the Client is retained for rebuilding, and
no detached control task is spawned, so nothing leaks when RedisState is
dropped and the existing #[derive(Clone)] still holds.

/debug/redis collapsed every failure into the single string
"redis_probe_failed", which is why the real error was invisible and the
outage took a session to trace. StateError::Redis carries the underlying
RedisError, so the class is readable here: authentication_failed,
io_error, busy_loading, client_error, response_error, plus
state_lock_poisoned and encryption_failed. ErrorKind is #[non_exhaustive],
so the match keeps a fallback arm for future variants.

No NotReachable variant exists in redis 0.27.6 -- a network outage
surfaces there as IoError or ClientError -- so the classifier does not
mention one.

Four new transitive dependencies come in with the feature: backon 1.6.0,
fastrand 2.4.1, futures 0.3.32, futures-executor 0.3.32.

Not covered: there is no test for the automatic reconnect path, which would
require tearing down a real TCP connection mid-flight. /debug/redis still
probes through is_enabled() rather than issuing a bare PING.

Gates: rust fmt clean, clippy --all-targets -D warnings clean,
cargo test 87 passed 2 ignored, unchanged from the previous baseline.
2026-09-30 09:13:38 +08:00
anonsaber 24a3f04dab docs(agents): record the parallel-coordination rules that stopped the rework loop
Same work, two instruction versions in the queue: the executing agent follows
the stale one while the lead has already finished and committed the revised
one. Nothing in the queue records which instruction is current, so which
version runs is effectively random. That is what the mutual-overwrite round
was, not a communication failure.

AGENTS.md gains a 9th section with the five rules, including the one that
actually had to be enforced: withdrawing an instruction requires telling the
executor by name, because replacing it in the queue is not withdrawal.

Mechanical follow-up work that cannot collide with someone else (anchor
recalculation, wording, missing tests) should be done while waiting rather
than queued. Committing stays with the lead.

GATE-DOCS 6/6, ANCHOR AUDIT 129 refs / 0, PATH AUDIT 454 / 0. AGENTS.md
248 -> 267 lines, outside the 500-line soft cap and the size checker only
covers src/, web/ and cloudflare-worker/.
2026-09-30 03:03:59 +08:00
anonsaber b764d6dc30 feat: reject stale business-config submits with a 409
revision becomes a request-body control field on BusinessConfigPatch. It is
never a setting: apply() and is_complete() ignore it, so the stored wire
never gains a revision key, and a PUT body that omits it keeps
last-write-wins for callers that never read the revision back.

The SPA records the revision it read, echoes it on every partial submit, and
on a 409 re-reads the stored values into the form and tells the operator to
recheck what they changed. Without that reload every retry would 409
forever; if the reload itself fails the SPA falls back to last-write-wins
rather than trapping the operator. The control field is excluded from the
empty-patch check so echoing it never counts as a change.

The compare-then-write is deliberately not atomic. Without a Lua script an
optimistic lock still has a residual window in which two requests racing
inside the same tick both pass and the later one wins; the window stays
small because a patch carries only the fields the operator actually changed.

notify.rs gained 18 lines before line 700, so its anchors in deployment.md,
opengaps.md and retired.md shifted by +18. Every one was checked against the
real line content, since the anchor audit only flags blank targets.

Gates: rust fmt/clippy clean, cargo test 87 passed 2 ignored, node --test
web/config.test.mjs 18 passed, docs_check/run_all.sh 6/6.
2026-09-30 02:56:15 +08:00