Commit Graph

126 Commits

Author SHA1 Message Date
anonsaber e3924a53c1 test: cover business config read-back and partial submit
Adds the missing test coverage for the GET /api/business-config read-back
and the partial-patch PUT, then syncs the docs to the behaviour the code
actually has.

src/notify.rs
- PUT response body now carries "revision" alongside the
  x-business-config-revision header, which the SPA surfaces in its status line.
- 4 test helpers (a router that returns (Router, MemoryState) so a config can be
  seeded first, a full valid wire value, a direct state writer that skips
  validation, and a wire value whose JMAP URL fails fast) plus 9 tests:
  - GET 401 leaks no business field at all (body is exactly error + request_id)
  - GET echoes the 10 non-secret values and returns secrets only as presence
    booleans; the serialised body is asserted to contain none of the 6 secret
    values (SAF-NO-SECRET-ECHO)
  - GET with nothing stored returns 200 configured:false revision:0, not 404
  - a first save with an incomplete patch is rejected and writes nothing
  - a partial submit replaces only the fields it carries, verified by reading
    the merged result back over the same path the SPA uses
  - validation runs on the merged result, so changing one field cannot bypass
    the rest, and a rejected submit neither persists nor bumps the revision
  - telegram_chat_id is accepted as a number or a numeric string and a garbage
    string is rejected without overwriting the stored value
  - a typo in a field name is rejected rather than silently dropped

src/config.rs
- Pure deserialisation test for de_i64_from_number_or_string, with no router and
  no store. This is the only place that separates "the custom deserializer
  refuses the string" from "a later validation step refused it" - a real
  distinction, since the SPA submits telegram_chat_id as a string on a partial
  submit while the read-back model returns a number.

cloudflare-worker/test/index.test.js
- The forwarded-route test asserted a fixed upstream origin, which is not stable:
  the LB rotates its start index between tests. Assert the origin is one of the
  configured backends and check the path and method exactly instead.

docs
- 17 spots bring the docs in line with the shipped contract: GET is now
  documented, PUT as a partial patch, the worker safelist at 16 routes, and the
  preflight path listed as forwarded.
- Corrections the docs had backwards: the response field is "configured"
  (revision is a u64, 0 when nothing is stored); Invalid returns 422, only
  Unreachable returns 503; there is no chat_allowlist_empty error code.
- "Blank keeps the stored value" is now stated as a client-side contract. The
  backend does not distinguish an omitted field from an empty string - apply()
  uses unwrap_or_else, so an explicitly submitted "" really does clear the
  secret (validation then rejects it). Saying it was server-side was wrong.
- POST /worker was listed as a forwarded route though it is not in SAFE_ROUTES,
  and the per-category counts did not match the lists they introduced.
- POST /api/bootstrap does not share the patch-merge contract; it deserialises a
  full BusinessConfigWire, which is the only path that can create a config from
  nothing.
- Re-anchored 9 src/notify.rs line refs that had drifted blank and raised the
  baseline to 86 passed / 0 failed / 2 ignored.

Gates: fmt, clippy -D warnings, test 86/0/2 ignored, worker 32/32, SPA 17/17,
GATE-DOCS 6/6 clean.
2026-09-30 01:24:42 +08:00
anonsaber 0cee50b4c9 feat: read business config back and accept partial patches
GET /api/business-config returns the stored values with presence-only secrets, and PUT now merges a patch against the stored config instead of replacing it, so the SPA can show what is already set and send back only the fields that changed.

The SPA marks secrets as set-but-unreadable, edits chat_allowlist as an unordered set, and omits fields it did not touch; blank required fields keep the stored value, blank optional fields clear it. The read-back is Serialize-only, so a client cannot inject secrets_present.

Forward both paths at the gateway: /api/business-config accepted PUT only, so the read-back returned 405, and the preflight path was missing from the safelist entirely, so it returned 404. The SPA is served from the Worker domain, so the whole edit experience was unreachable behind the production gateway. Route methods are now asserted per path, which the safelist test never checked.

Also: /debug/config reports timezone again, the timezone select is styled like the other fields, and the docs follow the new contract.
2026-09-30 00:53:58 +08:00
anonsaber 953a4db6f0 docs: document the notification rendering contract and sweep anchors
New reference.md 5.4 pins the user-visible contract: notifications are three
metadata lines only (From / Subject / Received), never the body and never an
LLM summary; the mailbox display form and its fallback literals; the
timezone field, its 16-zone whitelist, the 422 on anything else, and why DST
is not tracked. Registered as REQ-TIMEZONE-DISPLAY in charter 8.

deployment 5 gains the TIMEZONE row, including the SPA section and the
reason DST zones cannot be added.

Opengaps 1 drops two blockers that are verified done and moves the remaining
two to a "what you do / what I do" checklist.

Corrected doc claims that the line-number checker cannot reach:

- reference 6.2 and the Redis key table cited notify.rs 240/253 and
  252/253 for lock TTL and heartbeat; the lock is acquired at :289 and
  renewed at :302.
- The Redis key table's dedup:tg / dedup:jmap / ratelimit / stream call-site
  anchors all pointed into the ready() handler and into unrelated config
  helpers. Re-pointed at the real statement lines.
- Both Retry-After helpers were anchored at error_response 321-329 and
  error_response_with_id 1088-1094; the real definitions are 370-378 and
  1368-1375, and the readiness path passes retry at :210, not :162.
- design claimed summarize() is also called by notify.rs for new-mail
  notifications. There is exactly one call site, the /summary branch in
  worker.rs; notifications never call the LLM.
- design described list_emails as requesting an explicit property list and
  returning Vec<EmailSummary>; the code passes no properties and returns
  Vec<EmailMetadata>.
- retired listed 6 intents but only 5; worker.rs:533 for /search is :553.
- Config is 7 fields now that timezone is mirrored into it.

The anchor audit now flags any citation that lands on a bare brace, paren or
comma - the shape these drifted references had.
2026-09-29 21:48:04 +08:00
anonsaber 61de5f985f feat(spa): add a time zone selector to the business configuration page
Section 04 "Notification display" holds a single select populated from a
frozen catalogue of the 16 zones the server accepts, with Asia/Shanghai
selected by default. Labels are per-zone (`label.en` / `label.zh`) so they
follow the existing browser-language switch.

readBusinessConfig now submits the selection under the `timezone` key.
2026-09-29 21:48:04 +08:00
anonsaber d32d645322 feat(config): make the notification time zone configurable
The Received line was sent as a raw unix timestamp. It is now a business
configuration field (`timezone`, IANA name, default `Asia/Shanghai`), edited
in the admin SPA and applied through the existing 1s hot-reload.

The offline build has no IANA tz database (chrono-tz is unavailable), so the
server keeps a static table of 16 zones that carry no daylight saving
transition and rejects anything else with the existing 422 configuration
error instead of guessing an offset.

`format_received_at` renders to `%Y-%m-%d %H:%M` at notification time; a
timestamp outside the representable range falls back to `unknown`.

Chrono 0.4 was already in Cargo.lock as a jmap-client transitive dependency,
so this only promotes it to a direct dependency.
2026-09-29 21:48:04 +08:00
anonsaber 5e32fe842a fix(jmap): request the From property so notifications show the sender
read_email never asked Stalwart for `From`, so `EmailMetadata.sender` came back
empty for every message and the Telegram line rendered as `From: unknown`.

List page was unaffected: fetch_email_page passes no `properties` argument and
therefore receives the full default set, which already includes `from`.

sender is now formatted as the standard mailbox form - `Name <addr>` when the
display name exists, the bare address otherwise - so a human-readable name is
shown instead of a raw local part.
2026-09-29 21:48:04 +08:00
anonsaber 9d9f9d7ca2 docs: charter anchor baseline is now the latest code commit
The docs side of that line could never be self-consistent (it would always trail
HEAD by one commit), so it now points at main instead of a fixed SHA.
2026-09-29 20:56:08 +08:00
anonsaber 8b1afac430 docs: close two integration blockers and document the worker drain schedule
- opengaps: drop the Telegram inbound callback and Stalwart PushSubscription
  blockers, both verified live; 2 items remain (newState semantics, TTL
  measurement). The public-callback mapping question is also resolved -- the
  integration env has no Worker in front and Stalwart hits the backend origin
  directly, while /push/jmap is already in the Worker safelist for production
- deployment 4.1 (new): full setWebhook and /api/push/register procedure,
  expected replies, failure symptoms, and the setWebHook rotation trap (the
  deployed secret must be re-registered after any TG_WEBHOOK_SECRET change)
- deployment 6.2: the worker is neither a background task nor a separate
  container; POST /worker is the only consumer entry point
- deployment 6.3.1: add the /worker drain step to the cron and k8s CronJob
  examples, plus a two-endpoint comparison table, ordering requirements, the
  10-message batch cap, and the "204 is not a success signal" warning
- deployment 10.4: describe /worker as scheduler-driven, not manually invoked
- reference, retired: /worker anchor notify.rs:331 -> 380; the old value fell
  inside the reconcile function body, so the anchor audit passed while being
  semantically wrong
- charter 6.6: narrow the blocker list; code baseline b2dbe7c -> fc686ab
- design 11.2: PushSubscription permission now verified; the external Cron
  must call both /reconcile and /worker

GATE-P0: cargo fmt / check / clippy clean, 69 passed, 1 ignored
GATE-DOCS: 6/6 clean (ANCHOR 299, SECTION-REF 234, PATH 428)
2026-09-29 20:55:54 +08:00
anonsaber 94b6baf7bb docs: reframe SAF-DEBUG-ALLOWLIST as never a gap, not a risk acceptance
The PM's criterion: if the documented production posture already contains
the risk, it was never a code gap to begin with -- it was just a
description of current behavior. My previous wording said it was "closed
by risk acceptance", which implies an accepted open risk that someone
decided to live with. That is the wrong frame and reads worse than the
truth in a security review.

Reworded to state the actual reasoning: the exposure is contained by the
dual-factor debug gate and the Worker safelist, each sufficient alone,
so a third layer was never needed and nothing was accepted.

No behavior, code, or ID registration changed -- wording only.
GATE-DOCS 6/6: ANCHOR 270, SECTION-REF 226, PATH 394.
2026-09-29 20:02:15 +08:00
anonsaber bfd3fefd97 docs: close SAF-DEBUG-ALLOWLIST by risk acceptance
The PM accepts the residual on the strength of the two gates that are
already the documented production posture, so the item leaves the gap
file and docs/opengaps.md no longer has a 代码缺口 section at all.

Both gates verified in code before agreeing to the premise:
- closing debug is total: the dual-factor gate in src/main.rs:98-105
  feeds src/notify.rs:1340-1342, which only then merges debug_router();
  unset and the 7 routes do not exist, axum falls through to a generic
  404, so it is not a 401 and does not disclose the path exists
- the Worker refuses rather than forwards: cloudflare-worker/src/index.js
  :81-82 returns 404 route not forwarded for any path outside LB_ROUTES,
  and LB_ROUTES = SAFE_ROUTES holds exactly 15 entries with no /debug
  path. Confirmed by reading the list, not by trusting the README

So either gate alone is sufficient, which is stronger than the assumption
recorded in the gap entry, which treated the allowlist as the thing
keeping the other two apart.

The residual is already on paper and is the only real one: direct access
to the backend origin bypasses both. docs/deployment.md:103 says exactly
this, so nothing new had to be written, and design.md:349 now points
there and to docs/design.md §7.6 instead of at a section that no longer
exists.

- drop the allowlist row from the 你要做的事 table as well, or the table
  would advertise a task whose gap no longer exists
- docs/design.md:349 would have dangled on 「代码缺口」, so it now records
  the closure and its basis rather than a count of a deleted section
- docs/retired.md:81 says 「代码缺口」4 → 3. That is the recorded effect
  of the deletion it documents, so it stays as a past-tense fact rather
  than being rewritten to the current state of 0
- the behavioral fact stays where it belongs: docs/deployment.md:102 still
  documents that an empty allowlist does not block, and charter.md:279
  still registers the ID with its 启用须确认白名单已配置 condition.
  Only the gap status was removed
- GATE-DOCS 6/6 pass: ANCHOR 270 (was 272), SECTION-REF 226 (was 224,
  the two new cross-refs I added), PATH 394 (was 393, net +1 swap)
2026-09-29 19:59:33 +08:00
anonsaber e2d52d3b3b docs: make opengaps.md say what the operator must do
Every blocker now states the concrete action required, the owner, and the
expected result, instead of only explaining why it is blocked. The file
was answering 'what is broken'; nobody could tell from it what to run.

- new 你要做的事 table up front: 6 rows splitting each item between
  operator action and my action, so the reader sees at a glance which two
  items need a curl, which one needs an answer, which needs a downtime
  window, and which need nothing
- item 1 Telegram: setWebhook + getWebhookInfo commands, allowed_updates
  narrowed to message to match the only field the service parses
  (message.text), and the rotation trap spelled out -- changing the SPA
  secret without re-running setWebhook leaves Telegram holding the old
  value, so real callbacks start failing with 401 silently, no error
  and no alert
- item 2 PushSubscription: POST /api/push/register command plus the
  split of responsibility. Registration is performed by this service
  against JMAP, not in the Stalwart admin UI, and the verification code
  comes back over the callback and is verified automatically, so the
  operator never needs to obtain a code. Also documents the observable,
  the failure-code decode table, and the disable endpoint
- item 3 rewritten: the operator action collapses to a single question
  (which component does the public mapping), because no Worker is in
  front of this deployment. The five TTL keys moved into a table with
  exact keys, purposes, TTLs and line anchors, and the pending 900s /
  verified 300s statuses are documented where they are set
- item 4 spelled out as a concrete downtime window: stop the service
  while mail is still arriving, longer than Stalwart's delta retention,
  without running POST /reconcile during it, then I reconcile and check
  the cursor re-baselines instead of erroring
- corrected getWebhookInfo field: it returns last_error_date, not
  last_error_time
- anchor audit caught two of my own bad anchors on the first run -- the
  SPA-verification comment is at notify.rs:1163 and the callback URL
  validation at :1174, I had guessed :1151 and :1163, both off by 12.
  ANCHOR is now 272 refs / 0 errors, PATH 393
- GATE-DOCS 6/6 pass
2026-09-29 19:48:40 +08:00
anonsaber 4f44e92592 docs: close the Redis TLS blocker, confirmed live
- remove the 真实 Redis TLS 连接 blocker: user confirms the managed
  Redis is connected; the redis crate has tokio-rustls-comp enabled so
  rediss:// is a supported scheme, and /debug/redis + /ready both report
  reachable/true on the live deployment (checked just before this commit)
- the old item bundled three things: rediss:// handshake, password URL
  encoding, ACL password. The URL-encoding half was only ever covered by
  a parse-only unit test pointing at example.invalid, which never
  connects, so the handshake itself was the unverified part; the live
  connection covers all three
- blockers now 5 -> 4; no cross-reference lost: docs/deployment.md:231
  already documents the rediss://default:<url-encoded-password>@ form,
  and :220 already draws the startup-env vs business-config hot-reload
  line, so deleting the item drops no surviving fact
- docs/charter.md 6.6: the blocker enumeration mirrored opengaps.md and
  must move with it, and the credential preamble ("无 Stalwart /
  Telegram / 托管 Redis 凭据") was already stale since the Stalwart and
  Telegram creds both verified live, so it now states what is actually
  unverified, which is the inbound callback direction
- 代码缺口 has nothing to remove: its only remaining item is
  SAF-DEBUG-ALLOWLIST, which is deliberately unimplemented by product
  decision and src/debug.rs:233-235 still shows the empty-allowlist
  short-circuit
- GATE-DOCS 6/6 pass: ANCHOR 267 (was 269, the item carried 2 refs),
  SECTION-REF 224, PATH 390 (was 392)
2026-09-29 19:44:05 +08:00
anonsaber d4c4c1d09b docs: rename roadmap.md to opengaps.md and drop completed items
- rename docs/roadmap.md -> docs/opengaps.md: file now holds only
  still-open / still-unverified items, so the name matches its content
- removed items proven done during the 2026-09-28 live integration run:
  /search E2E, Telegram 429 backoff, multi-instance duplicate window,
  /ready end-to-end dependency probe, JMAP read-only adapter real-device
  verification, real platform log collection acceptance
- removed the C-NO-LONG-CONN long-polling item (permanent non-goal,
  already registered in docs/retired.md) and the duplicated Redis ACL
  encoding clause (merged into the Redis TLS item)
- removed the whole 验收待办 section (all 3 items resolved)
- narrowed the phase goal to setWebhook + PushSubscription callback chain
- baseline corrected 61 -> 69 passed / 0 failed / 1 ignored, re-measured
  2026-09-29 with cargo test --locked --bin message-weave
- docs/charter.md D-G1-1 marked real-device verified, closing the
  contradiction with docs/design.md:397/:600
- update the DOCS list in all 4 docs gate scripts + TARGET_BEFORE regex
- GATE-DOCS 6/6 pass: VALIDATION, TABLE, ANCHOR 269, SECTION-REF 224,
  SIZE, PATH 392
2026-09-29 19:38:38 +08:00
anonsaber b3568b63f2 docs(roadmap): drop unsupported config-key/redis-url hot reload, mark verified integration paths 2026-09-29 19:22:05 +08:00
anonsaber 9ce0c9f40c docs(roadmap): fix two stale anchors for the webhook and retry-exhaustion code 2026-09-29 19:17:15 +08:00
anonsaber fc686abc22 config: persist business config regardless of client build, add preflight probe
PUT /api/business-config and POST /api/bootstrap now treat validation as the only
write gate: a valid wire is always persisted, and only then are the clients built
and the running worker swapped. A failed build returns 200 with persisted=true,
runtime_applied=false and a warnings array of {component, step, detail} instead of
503, so an unreachable JMAP/LLM no longer turns a valid configuration into a silent
data loss. The previous worker keeps serving.

POST /api/business-config/preflight runs the same validation and client build
against the submitted wire without writing anything and without touching the
running worker. It answers 200 even when the configuration is bad, because that
is the answer the caller asked for; components is null after a validation
failure, and llm is null rather than vacuously ok when LLM is disabled.
Authentication is the admin-session gate, not DEBUG_TOKEN.

The SPA drops the account_id field (the backend still accepts it; omitted falls
back to the session primary account), adds a Test connection button, and shows
the build fingerprint in the footer, the topbar and the setup card. The
503 message no longer claims JMAP/LLM build failure, which is not what produces
it.

BusinessConfigWire.account_id gains #[serde(default)]; without it, omitting the
field in the wire body made deserialization fail outright.

build.rs bakes BUILD_VERSION (git sha or nogit, plus a UTC build timestamp) which
GET /api/status exposes as version.
2026-09-29 18:25:56 +08:00
anonsaber dda8e084ae fix(jmap): trust the configured origin host so Stalwart's session redirect is followed
jmap-client refuses to follow any redirect unless the target host is on a
trust list that defaults to empty. Stalwart 307-redirects /.well-known/jmap
to /jmap/session, so connect() aborted with "Aborting redirect request to
unknown host 'mail.motofans.club'" and PUT /api/business-config failed with
a 503 carrying no usable detail.

connect_with_runtime now passes the origin host parsed from the normalized
session URL to follow_redirects, trusting that host only -- a target host the
server invents is still refused. An unparseable base yields an empty host,
which matches no target and preserves the previous fail-closed behaviour.

Verified against a real Stalwart instance both ways: the ignored smoke test
session_list_and_read_smoke now passes (session -> folders -> email list ->
read) and fails with the redirect error when the change is reverted.

docs: record the redirect trust under SAF-JMAP-URL, mark G1/D-G1-1 verified
after the real-server run, and refresh two shifted line anchors in client.rs.
2026-09-29 16:30:55 +08:00
anonsaber 80d13c0292 fix(notify): stop exposing the business and admin routes in configuration-setup mode
The setup router delegated to the full factory, so all 12 business and
admin routes were registered even though not one could ever work:
admin_token is an empty SecretString and constant_time_eq has a
!expected.is_empty() guard, so an empty expected value rejects every
candidate forever. MemoryState could not persist a bootstrap write either.

Those routes answered 401 {"error":"unauthorized"}, which is
indistinguishable from a mistyped credential. An operator or a probe
reads that as "try a different token" when the truth is "these
endpoints do not exist in this mode".

router_configuration_setup now builds its own AppState and mounts only
/api/status, /ready and /healthz. Verified live with no env vars:
healthz 200, ready 503, /api/status 200 with the missing list, and all
12 business/admin routes 404. The SPA is unaffected: /api/status is the
only endpoint it calls at startup, and web/config.js reveals the
admin-session card only when ready === true.

Added configuration_setup_mounts_only_status_and_probes to guard the
route table, and configuration_setup_call as its helper.

Docs: reference.md section 3 and 5.1 now describe the reduced surface
and cite the empty-expected-value guard; deployment.md's probe table had
/api/status listed as 503 {"status":...} when it returns 200 with a
"mode" field; retired.md section 4.1 records the removed route
registrations. Anchors refreshed for the +19 lines in notify.rs.
2026-09-29 15:26:54 +08:00
anonsaber 4cf128fcc8 feat(debug): 开关信号改为 DEBUG_ENABLED 环境变量,启动命令不再需要改动
起因:用户反馈会临时改启动命令(比如给 start 加 --debug)。而 hoststack.yaml
声明的 start.command 优先级最高且每次部署都覆盖控制台旧值,所以每次开关调试面
都得改 YAML、提交、推送、部署——最麻烦的那条路。

改法:把双因子的「开启信号」从单一 argv 变成「argv 或 env 二者之一」:
- --debug 保留,本地开发照旧用
- 新增 DEBUG_ENABLED(1/true/TRUE/True/yes/YES,精确匹配),生产走控制台环境变量
- DEBUG_TOKEN 仍然必须非空。刻意不让 token 单独生效——token 是平台上的长期
  Secret,让它自己开门就等于把双因子退化成单因子

收益:生产开关调试面 = 控制台改一个 env + 重新部署,不改代码、不改 hoststack.yaml、
不重新构建。启动命令从此静态,顺带消解了"YAML 还是控制台"的争论——命令不再需要变,
放哪都无所谓。

实现:把判定抽成纯函数 debug_surface_requested(debug_arg, debug_enabled_var),
不直接读全局 env。env 是进程级全局态,测试里读它会和同二进制内其他测试竞争;
调用方传入已取好的值,测试就能覆盖真值集合、假值集合(含 "TRUE " 带空格、
"debug" 这类近似值)与"开了开关但没 token 仍然关闭"。

文档:deployment.md §2.1 双因子条件重写 + 环境变量清单;§5 表格;§5.1 启动命令
说明;reference.md §3 表与调试面描述;design.md 架构图注与 LLM_ALLOW_NET 锚点;
charter.md 两条 ID 描述。hoststack.yaml 的 start 注释从"临时加 --debug"改成
"用 DEBUG_ENABLED 切"。

顺带修掉加代码引发的锚点漂移(新代码把 main.rs 后面的行号整体后移 13):
design.md:366、design.md:739、reference.md:205/335/336/380 共 6 处。

验证:cargo test --bin message-weave = 65 passed / 0 failed(新增 4 个测试,
含真值集合、假值集合、无 token 仍关闭)。GATE-DOCS PASS 6/6,259 line-refs 全绿。
2026-09-29 12:54:06 +08:00
anonsaber 633b41394c docs: 回答 hoststack.yaml 的来历与官方做法,补上命令字段不回写的证据
回答用户追问「这个文件哪里来的、官方推荐怎么做」。git 考古 + agent 源码两条路:

来历:a571033 自己手写的(2026-09-25),不是 HostStack 生成的。agent 镜像里既没有
init 类命令也没有内嵌模板 YAML(find 全仓 0 个 yaml 文件),只负责在仓库根读取
hoststack.yaml 或 hoststack.yml。所以"官方推荐怎么写"没有标准模板可抄,只有 schema。

官方做法:源码自己的定位是 Infrastructure-as-Code——
  "hoststack.yaml is Infrastructure-as-Code: when the repo declares a command,
   it wins over whatever happens to be cached on the services row"

新证据,把"UI 更方便管理"这个前提彻底否掉:agent 里只有两个 YAML->DB 回写事件
(yaml_port_declared、yaml_disk_declared),**命令字段没有**。注释原文明说
"the two that need to reach the services row have their own handled frames"。
所以每次部署都是拿 YAML 临时覆盖 services 行里的旧值,DB 里永远是过期的
./target/release/app。port 与 disk 要回写是因为 autoscaling 和 sleep-wake 直接
读那行;命令只有部署路径自己读,不值得持久化。

结论:控制台对命令字段永远是过期缓存,不存在"以 UI 为准"的稳态。
要删 hoststack.yaml,必须先改控制台那三条命令,文档里已有此前提。

audit_paths.py:hoststack.yml 加入 CONVENTIONAL allowlist——它是 yaml-parser
实际接受的第二种文件名,与已在白名单里的 docker-compose.yml 同类。

验证:GATE-DOCS PASS 6/6(PATH AUDIT 378 path-refs / 0)。
2026-09-29 12:25:08 +08:00
anonsaber cf252a9f06 docs+build: Dockerfile 降级为仅本地开发用,并记录 hoststack.yaml 保留/移除决策
用户要求两件事:把 Dockerfile 明确降级成「仅本地开发用」并说清边界;
以及若日后决定移除 hoststack.yaml,把理由写进相关文档。

先核了一遍决定性的技术前提——删掉 YAML 到底会发生什么。读 HostStack agent
源码,优先级是三层,而且和我上一轮写的说法不一样:

  1. 控制台 services 表已存的值 -> payload
  2. YAML 声明的字段无条件覆盖(guard 只是 `if (svcConfig.start?.command)`,
     没有 `&& !payload.x` 短路)
  3. YAML 省略的字段沿用控制台值;框架默认值只在仍为空时才填,而 agent 自己的
     注释写着 "trigger-payload always populates payload.x from the DB",
     所以对已建好的服务这条分支实际是死代码

因此删掉 hoststack.yaml **不会**回落到 cargo fetch / app 那套默认值,而是直接
采用控制台现存的值——而那恰好就是坏掉的 ./target/release/app。"UI 更方便管理"
这个前提成立,但必须先改 UI 再删文件。据此写了决策记录,当前决定是保留。

Dockerfile / docs/deployment.md §3 / docs/design.md / README.md
- 统一标为仅本地开发 / 本地容器调试路径,生产不执行
- 写清边界后果:诊断工具、ENTRYPOINT tini、EXPOSE 8080 在线上都不生效
- 给出判定方法:容器内看 /proc/1/cmdline 与 /etc/os-release
- 顺带修掉 design.md 里把 hoststack.yaml 描述成"网关编排清单"的错误——
  文件里没有任何 gateway,只有 services.web

docs/deployment.md §5.1
- 三层优先级替换掉原先"YAML 省略则回落框架默认值"的错误说法
- 新增决策记录:保留的理由(进 git、可 review 可回滚)与代价(双写);
  若日后移除,必须在控制台先把 install/build/start 三条改对再删,并把本段
  移到 docs/retired.md 记下移除 commit

验证:GATE-DOCS PASS 6/6(SECTION-REF 226 refs / 0,PATH AUDIT 376 refs / 0)。
2026-09-29 12:22:00 +08:00
anonsaber 978288f8d7 build+docs: 用 HostStack agent 源码实证校正部署事实,砍掉 dig 的 42.7MB 膨胀
从本机缓存的 registry.hoststack.dev/hoststack/agent 镜像里直接读出运行时
配置,纠正了此前靠猜的四条部署事实:

hoststack.yaml
- 撤回上一版把 install: 合并进 build.command 的"修正"——schema 里
  install.command 本来就是合法键(agent 的 schema 与 yaml-parser.ts 均定义),
  install/build/start 是三条独立命令
- 补上为什么这个文件不能删:runtime: rust 的框架默认 start 命令是
  ./target/release/app,与 Cargo 包名无关。删掉 YAML 后 Start Command
  回落到 app,进程在绑定端口前就以 not found 退出
- 记录实测优先级:YAML 值生效,省略才回落框架默认值(Cargo.toml 只声明
  [[bin]] message-weave,仓库里既无 app 目标也无 app.rs)

Dockerfile
- 移除 bind9-dnsutils:连带 libicudata.so.72(29.8MB)等多级依赖,实测
  边际成本 42.7MB,是其余 7 个工具总和(19.7MB)的两倍多
- DNS 解析用基础镜像自带的 getent 已覆盖(getent hosts / ahostsv4 / ahostsv6)
- 注释写明 19.7MB 的实测口径

docs/deployment.md
- §5.1 重写:build 在 rust:slim-trixie、runner 是 debian:trixie-slim,
  本仓库 Dockerfile 完全不被执行
- 修正"没有 install: 键"与"validate 拦未知键"两处错误说法:agent 部署路径
  不应用 zod 校验,未知键被静默丢弃而非报错,validate 才是唯一防线
- §4.1 容器安全档改成源码实测值:readonlyRootfs/dropCapabilities/
  noNewPrivileges 全为 true,pidsLimit 256,tmpfs 选项 rw,nosuid,nodev,
  size=64m——原先写的 noexec 并不存在;并标注 dev 档 rootfs 可写
- §8.1 明确两条部署路径的镜像差异(bookworm vs trixie)
- §6 HEALTHCHECK 与二进制"内置 health 子命令"的过期说法
- 删除文档中指向不存在文件 app.rs 的引用

验证:GATE-DOCS PASS 6/6(PATH AUDIT 369 path-refs / 0);
runtime 阶段实测构建成功且 curl/ps/pgrep/ss/ip/jq/nc/getent 齐备、
dig 按预期缺席、getent hosts 可解析、uid 999;
8 个工具 dpkg Installed-Size 合计 7935kB 而全量 du 差 62.3MB,
剔除 dig 后 19.7MB——这个差额就是本次砍掉的膨胀。
2026-09-29 12:15:52 +08:00
anonsaber 417f378d68 build+docs: 镜像内置只读诊断工具,清理 Dockerfile 的死 ENV 与文档过期表述
生产镜像此前只有 ca-certificates + tini,容器 rootfs 又只读,运维在容器内既
装不了 apt 也没有 curl/ps,排查只能靠宿主机的 /proc。把只读诊断工具装进
runtime 阶段,实测体积 +7.7 MB:

  curl procps(ps/pgrep/free) iproute2(ss/ip) jq bind9-dnsutils(dig) netcat-openbsd(nc)

全部是只读查询能力,不引入包管理之外的写能力,也不新增 shell 解释器。

顺手修掉四处已被事实推翻的表述:

  1. Dockerfile 的 ENV RUN_MODE=webhook 是死变量——该标识符随 Config::from_env()
     删除后代码零读取,文档 5 处都记着已删除,镜像却还在设。删掉并留注释防回退。
  2. deployment.md 说「runtime 只装了 ca-certificates tini」「镜像内没有 curl/wget」,
     两处都是健康检查建议的承重理由,随内置 curl 一起更新为「技术上可行但不建议」。
  3. deployment.md 说非 root 用户是 uid 1000,实际 useradd --system 给 uid 999,
     已实测修正。
  4. retired.md 里 compose healthcheck 示例的删除理由把「镜像无 curl」当主因,
     真正的承重原因是 src/main.rs 没有 CLI 子命令解析,已改写并标注 curl 已内置。

同时补齐 hoststack.yaml 缺失的 install 段(cargo fetch --locked),并在 start 段
写清临时开 --debug 的确切改法与「必须在容器内探、别走公网域名」的约束。

验证:
  - 包名与安装步骤在同一 debian:bookworm-slim 基础镜像内实测通过
  - useradd 实测输出 uid=999,与设计一致
  - docker build --check 无告警
  - GATE-DOCS PASS 6/6(SECTION-REF 217 / 0,PATH AUDIT 364 / 0)

注:install: { command: ... } 的键名是按现有 build:/start: 的同构推断的,
若 HostStack 拒绝该键,删掉这两行即可,不影响 build/start。
2026-09-29 11:24:13 +08:00
anonsaber 55a2bf5a14 docs: 修正 6 处代码行号锚点漂移,补 1 处缺失锚点
全量人工核对 44 个 `file.rs:行号` 锚点,发现并修正 6 处解析通过但指向错误行号的锚点
(`audit_anchors` 只验证锚点可解析,不验证该行是否承载文中所述内容):

- src/main.rs 的 `--debug`/DEBUG_TOKEN 双因子读取块已从 96-109 上移到 82-92:
  deployment.md:62 (96→82)、:63 (97-100→82-89)、:65 (107-109→90-92),
  reference.md:228 (96-100→82-89)
- SendMessage 结构体在 channel.rs:60 而非 :10(原指向 TELEGRAM_BACKOFF_BASE_MS 常量)
- reference.md:206 生产路由合并点是 main.rs:180 而非 :194;205 的 setup 路由锚点同处校正
- design.md:739 的 llm 构造分支锚定 111-127 整段而非孤立的 :125
- deployment.md:69 补上 worker_authorized 自身定义点 (notify.rs:446),
  原先只有 constant_time_eq (1136) 一个锚点,读者跳转后看不到被命名的符号

校验:GATE-DOCS PASS 6/6,ANCHOR AUDIT 250 line-refs / 0 errors,
PATH AUDIT 363 path-refs / 0 errors,人工 dump 复核 44/44 锚点落点正确。
2026-09-29 10:14:03 +08:00
anonsaber bfbaae6f01 fix(docs_check): make audit_paths' brace-glob handling actually work
Two advertised mechanisms were dead code, found by self-testing the checker
rather than trusting its 0-error output:

1. variants() could never see a brace glob. Its segment class excluded ',',
   so `channel/telegram/{mod,commands}.rs` tokenised as two bogus fragments,
   `channel/telegram/` and `commands}.rs`. The glob was still caught as an
   error, but only the first alternative was ever reported and it was
   reported under the wrong filename — a reader chasing that error would not
   find `commands}.rs` in the document. ',' is now in the segment class and
   removed from PLACEHOLDER, so the glob stays whole, expands per variant,
   and the message shows the glob alongside the failing alternative:
   `channel/telegram/{mod,commands}.rs -> channel/telegram/mod.rs`.

2. The docstring attributed the non-path exclusions to is_noise(). They are
   not: JMAP methods (Email/changes), media types (application/json), dotted
   hosts and template URLs are dropped by the whole-span rule, and absolute
   paths never arrive at all because TOKEN's lookbehind refuses to start a
   match after a '/'. is_noise() is reachable fallback for dot-prefixed URL
   parts and whole-span seg/ tokens. The docstring now says which rule does
   which, so the next reader is not pointed at the wrong function.

Verified, not assumed. A scratch doc with src/util/, src/domain/llm/client.rs,
tests/telegram_dispatch.rs, run_mode.rs and one brace glob yields exactly
5 errors and exit 1. src/ai.rs, src/domain/jmap/client.rs, docs/todo.md
(git-deleted), a stalwartlabs/ citation and docker-compose.yml all pass, as do
Email/changes, application/json, api.telegram.org/bot, 200/503,
.well-known/jmap, {jmap_origin}/.well-known/jmap and a log-format string.
GATE-DOCS still PASS (6/6), 362 path-refs checked, 0 errors.
2026-09-29 09:35:08 +08:00
anonsaber 4c7cbe66b9 docs+tooling: no document may name a path that does not exist
Not even the retired registry — there a fictional path reads the same as a
real one, so nobody can tell "we decided against this" from "this is wrong".

docs
  design.md   external-repo citations now carry their owning prefix
              (stalwartlabs/jmap-client/src/client.rs), so a bare
              src/client.rs can no longer read as a local file; the LLM
              layer, the local-write red line and the missing-test-dir notes
              stop quoting directories that were never created
  retired.md  header states the rule; invented entries are rewritten as named
              proposals ("把渠道层建成目录模块") instead of as paths that
              look like files; docs/todo.md stays, git history proves it
  AGENTS.md   §7.4 records the rule as a gate requirement
  charter.md  §6.3 validator count and scope

tooling
  audit_paths, the sixth docs_check validator: every path-shaped token in
  inline code in docs/*.md and AGENTS.md must resolve against a real root,
  or carry an external owning prefix, or be provably deleted in git history,
  or sit on a short commented allowlist. Non-path shapes are excluded
  structurally — absolute paths and URIs, dotted hosts, media types, JMAP
  method names, numeric pairs, glob fragments — and a seg/ token only counts
  as a directory when it is the whole inline-code span, so Email/changes and
  application/json are never mistaken for dirs. run_all.sh is now 6/6.

Verified, not assumed: a scratch doc with three invented paths yields 3
errors and exit 1, while a real file, a git-deleted file, an
external-prefixed citation and the conventional-name allowlist all pass.
2026-09-29 09:28:29 +08:00
anonsaber 1ecba9d961 docs(tooling): close the phantom-identifier blind spot for bare filename anchors
audit_anchors.py's phantom check only ran on anchors carrying a line number
(`.rs:NN`), so a claim written next to a bare filename was never checked. And
fixing the regex alone would not have been enough: the verdict was a substring
test, so `TelegramClient::send` passed twice over -- `send` is a substring of
`send_text`, and reqwest's `.send()` is `send` at a word boundary, both in
src/channel.rs.

Adds PAIR_BARE for the bare-filename shape plus a stricter member_present test
(a definition site, or a reference not preceded by `.` or a word char) for it.
The numbered PAIR branch is untouched, so both the established
"248 line-refs checked, 0 errors" baseline and the `checked` counter are
unchanged.

The numbered branch deliberately keeps the loose test: a `T::member` claim next
to `foo.rs:NN` still passes when the member only exists as a longer name, but
there the anchor hands the reader a line to read, which is a weaker but honest
guarantee than the bare shape can offer.
2026-09-29 08:45:12 +08:00
anonsaber af13e507a0 docs: bump verification baselines to b2dbe7c
Four doc headers reference the code commit whose line anchors they were
read against. The trait deletion moved src/channel.rs (361 -> 340) and
src/domain.rs (16 -> 10), so the anchors are re-based to that commit.

GATE-DOCS: PASS 5/5 (VALIDATION 0, TABLE 0, ANCHOR 248 line-refs / 0,
SECTION-REF 216 / 0, SIZE CHECK 0).
2026-09-29 02:24:17 +08:00
anonsaber b2dbe7ccbb refactor: delete three zero-implementor 'stage 0 placeholder' channel traits
src/channel.rs had Channel / Notifier / MessageAdapter, each marked
#[expect(dead_code, reason = "稳定ID+阶段0占位…")]. All three had no
implementor, no caller and no dyn binding: Telegram outbound actually goes
through channel::telegram::TelegramClient::send_notification -> send_text,
held directly by worker.rs's MetadataWorker and notify.rs. The traits were
never on any path.

- delete the three traits + their #[async_trait] attributes
- drop the async_trait import from channel.rs (unused afterwards); the
  dependency stays in Cargo.toml, still used by 5 other files
- drop the domain import from channel.rs (its only uses were inside the
  deleted traits; the telegram module has its own Notification import)
- delete UserCommand from src/domain.rs -- its only consumer was Channel.
  Notification stays (worker.rs:234 builds it)
- rewrite channel.rs's module doc: "only an adapter boundary in stage 0
  (no bot commands yet)" was already false -- /help, /search, /summary
  and /ai on|off are all parsed in worker.rs::parse_intent

The three dead-code attributes were the last three #[expect(dead_code)] in
src/. Two #[expect(clippy::too_many_arguments)] remain in notify.rs; those
are justified lint suppressions on router wiring, not placeholders.

External contracts unchanged: HTTP routes, Redis keys, error codes and the
error envelope are untouched.

GATE-P0: fmt / check / clippy -D warnings green, 61 passed / 0 failed / 1
ignored (unchanged).
2026-09-29 02:23:38 +08:00
anonsaber 4c09310a2f docs: bump verification baselines to 2da11b9
Line anchors in reference.md / retired.md were re-read against the compat-cleanup
commit, and the test baseline in charter.md and roadmap.md moves 62 -> 61 to match
the deleted from_env()-driven case.

GATE-DOCS: PASS 5/5, ANCHOR 247 line-refs / 0, SECTION-REF 216 / 0.
2026-09-29 02:03:49 +08:00
anonsaber 2da11b9f92 docs+fix: delete the legacy env compat layer; sync every doc that described it
Code (src/config.rs 521->394, src/main.rs 232->200):
- Config::from_env() + required_secret / required_nonblank / env_bool deleted.
  config.rs now performs no std::env read; all business fields come from Redis
  business config (BusinessConfigWire -> validate_business_wire -> validate_nonblank).
- RUN_MODE deleted: variable, Config.run_mode field and the main.rs validation
  block. The two modes never changed runtime behaviour -- webhook and reconcile
  share one router, POST /reconcile is a standalone endpoint.
- jmap_password() accessor deleted; the field name is kept in
  BusinessConfig / BusinessConfigWire because it is real data, not a stub.
- Config.port and Config.redis_url deleted: both had zero readers, and port was
  hardcoded to 8080 in both constructors while the listener binds straight from
  the PORT env var, so the field could silently disagree with the real port.
  Config is now 6 fields: telegram / jmap / account_id / llm / auth / worker_token.
- redis_only() and from_business(value) signatures simplified accordingly.
- #[expect(dead_code)] count in src/ is now 0.

Test coverage: the from_env()-driven startup_rejects_blank_auth_secret case is
gone with the path it tested. The non-leak assertion it carried was reattached
to the real validation route (BusinessConfigWire -> TryFrom -> validate_nonblank)
with a stronger third assertion that the rejected value is not echoed either.

Docs: design.md 7.1 re-scoped to the Redis business config table; reference.md
5.1/5.3 rewritten (no legacy env path); deployment.md 5 and the runtime-mode
table rewritten; retired.md gains section 4 registering every deletion;
README x2, .env.example, charter.md updated.

GATE-P0: fmt / check / clippy -D warnings green, 61 passed / 0 failed / 1
ignored (was 62; the deleted case). GATE-DOCS: PASS 5/5, ANCHOR 247 line-refs /
0, SECTION-REF 216 / 0.
2026-09-29 02:02:44 +08:00
anonsaber 6334ae541d docs(charter): move both baselines to 638c3a8
638c3a8 added one marker line to the top of six source files and re-based all
144 line anchors in the same commit, so that commit is the baseline for both
the code anchors and the document section refs. e0882c7 and b70d188 are no
longer valid baselines.
2026-09-29 01:17:29 +08:00
anonsaber c8af429ffe fix(mode): restore executable bit on the doc validators
audit_anchors.py lost its exec bit and check_file_size.py was created without
one, so the scripts/ directory no longer had uniform permissions. run_all.sh
invokes them through python3 so nothing broke, but every sibling validator was
100755.
2026-09-29 01:16:43 +08:00
anonsaber 638c3a8cbc docs: consume all four self-checks; make the file-size rule enforceable
Every member's audit is now read and each real finding fixed, not just reported.

AGENTS.md §2.1
- The 500-line soft cap now has a machine-checkable exception: an over-limit
  file must carry a one-line `SPLIT-EVAL:` marker stating the split decision.
- Six files were over the cap with no justification: notify.rs 2036, state.rs
  1586, worker.rs 1045, config.js 1079, client.rs 601, config.rs 521. All now
  carry the marker. client.rs was missed by my own earlier line count, which
  did not descend into src/domain/jmap/.
- New validator scripts/docs_check/check_file_size.py; GATE-DOCS 4/4 -> 5/5.
  It proves the marker exists, not that the written reason is sound.
- Re-based 144 line anchors across six docs for the +1 line each file gained;
  every shifted anchor verified against the code, not just for non-blankness.

audit-arch.md
- design.md §6.1 module matrix omitted the `debug` module entirely; added.
- Seven numeric duplications in design.md now defer to reference.md §6.1 as
  the single authority instead of restating the values.
- reference.md §5.1 was missing DEBUG_TOKEN; §5.3 listed six recognised names
  and guessed `JMAP_*`. It now lists all 20 exactly as config.rs reads them.
- Corrected two wrong claims in the audit itself: debug.rs is compiled
  unconditionally, not under #[cfg(debug_assertions)] (there are no cargo
  features at all), and /debug/* exposes 7 routes, not the invented session
  list / resend / rebuild surface.

audit-fileplanner.md
- reference.md cited a nonexistent `jmap_service.rs`; replaced with the real
  adapter chain worker.rs -> domain/jmap.rs -> domain/jmap/client.rs.
- cloudflare-worker/README.md was outside every validator's scope; all four
  doc validators now include it (208 section refs, 250 line refs).
- README.zh-CN.md stated only half of what README.md says about RUN_MODE; the
  forward-placeholder sentence is now bilingual.
- charter.md §7 boundary table was missing rows for itself and for web/.

audit-rust.md / audit-frontend.md
- Error envelope corrected: `error` is a bare machine code, no `message`.
- Status table, AI-consent labels and ~15 stale or phantom facts fixed against
  notify.rs; `TelegramClient::SendMessage` and `web_config` phantoms removed.
- GET|PUT /api/enabled forwarded by the worker so the SPA kill switch works.
- Ten UI strings that called the admin credential the Redis ACL password now
  say CONFIG_ENCRYPTION_KEY.
- POST /healthz returns 405; dead HTTP_STATUS removed.
- audit_anchors.py now flags identifiers that do not exist in the file they
  are anchored to.

charter.md
- `SAF-ENABLE-FLAG` registered (85 IDs); `SAF-ADMIN-SESSION` row corrected;
- `REQ-PUSH-TYPES` registered; the stale `admin_session_digest` removed.

Gates: GATE-DOCS PASS 5/5 (250 line-refs, 208 section refs, 0 errors);
GATE-P0 62 passed / 0 failed / 1 ignored, baseline unchanged; worker and SPA
36 tests, 0 failures.

Real Stalwart / Telegram / managed Redis verification stays blocked on missing
credentials and remains labelled as unverified.
2026-09-29 01:16:11 +08:00
anonsaber e0882c7c68 docs: de-projectize AGENTS.md into language-agnostic norms; add docs/charter.md
AGENTS.md (276 -> 239 lines) is now a language-agnostic engineering-norms
document: code style, config and secrets handling, build environment,
testing gates, document governance. It states no project fact and names no
language, framework, dependency, or file. Verified: zero hits for JMAP,
Telegram, Redis, axum, Cloudflare, LLM, *.rs, and every project env var.

All project-specific content moved to the new docs/charter.md (~276 lines):
project goals, locked technology choices, 23 numbered security invariants,
implementation stages, prohibitions, test/acceptance gates with the full
GATE-P0 command and baseline, document boundary rules, and the stable ID
registry.

docs/charter.md §8 is the single authority index for stable IDs: 83 rows,
no duplicates, ID | definition file | one-line statement | category. Four
IDs that were referenced across the docs but never registered are now
registered: ARCH-STATE-REDIS, C-REDIS-EXTERNAL, GATE-UPTIME-KUMA, GATE-DOCS.
All previously registered IDs are preserved.

Defect fixed while rebuilding the registry: SAF-ADMIN-SESSION cited
src/config.rs `admin_session_digest`, a name that has 0 hits in src/ -- a
phantom identifier that survived in the authoritative registry since before
this restructure. Corrected to `session_digest` (src/config.rs:7). All 8
distinct backticked symbols in the registry's definition-file column now
verify against src/.

Two false project facts removed from the docs (both claimed the SPA writes
through POST /api/bootstrap, which web/config.js never calls -- 0 hits; the
actual path is PUT /api/business-config): README.md, README.zh-CN.md,
docs/reference.md. The first successful write creates the configuration and
later writes hot-reload it; POST /api/bootstrap remains the one-shot
automation endpoint.

Cross-reference redirections in 9 files: README.md, README.zh-CN.md,
docs/deployment.md, docs/design.md, docs/reference.md,
cloudflare-worker/README.md, cloudflare-worker/src/index.js,
cloudflare-worker/wrangler.toml now point project facts at charter.md and
language-agnostic quality rules at AGENTS.md.

Four validators extended to cover docs/charter.md: DOCS lists in
audit_anchors.py, check_tables.py, validate_docs.py, check_sec_refs.py, plus
charter.md added to check_sec_refs.py's TARGET_BEFORE regex and to
validate_docs.py's index-file cross-doc exemption.

Gates: GATE-DOCS PASS 4/4 (VALIDATION 0 errors, TABLE STRUCTURE 0
mismatches, ANCHOR AUDIT 227 line-refs / 0 errors, SECTION-REF AUDIT 191
refs / 0 errors). GATE-P0 PASS, baseline unchanged 62 passed / 0 failed /
1 ignored. Anchor audit proves line reachability only, not that the line
contains what the prose claims.
2026-09-28 23:39:26 +08:00
anonsaber 526a73b8b2 docs: bump anchor verification baseline to b70d188
main.rs shrank by 2 lines in b70d188, moving 6 line anchors. The feature
markers for /search (bfe0fd8) are left untouched as historical references.
2026-09-28 22:34:52 +08:00
anonsaber b70d188864 docs+fix: sweep stale and phantom references; log the actual bound port
Code (2 sites):
- main.rs logged bootstrap_config.port, which is always the hardcoded 8080 in
  the two-variable production config; the listener actually binds the port
  parsed at main.rs:31-34. Log the bound port.
- That exposed Config.port as genuinely unread (clippy -D warnings), so mark it
  with #[expect(dead_code, reason=...)] instead of silently deleting a
  documented env-parsing site.

Docs, phantom identifiers (names that do not exist in src/):
- RuntimeConfig x3 -> OutboundConfig via RuntimeConfigProvider (state.rs:34/41)
- LlmClient::noop() x2 -> None in Option<Arc<LlmClient>> (main.rs:125)
- main.rs::validate_env_or_exit x2 -> config.rs:338 read, main.rs:75 validation
- TelegramChannel::send_message -> TelegramClient::send_text (channel.rs:134)

Docs, stale facts:
- LLM table: ENABLED and ALLOW_NET defaulted true,true -> false,false;
  API_KEY, BASE_URL, MODE were "required" -> only required when enabled. This
  contradicted design.md:385-387, which already had the correct values.
- design.md:560 said RUN_MODE "is actually consumed"; it is only read and
  validated and never affects behaviour (main.rs:73-74).
- RUN_MODE operator docs (README, reference, deployment, .env.example) now say
  both accepted values produce identical behaviour.
- retired.md: 5 intents -> 6 (/search shipped, worker.rs:517); 4 LLM env vars
  -> 6; main.rs tracing events 6 -> 7.
- design.md:494 tracing count 6 -> 7, debug-gate warning added to the list.
- design.md:350 code-gap count 2 -> 1 (multi-instance window done in 6c99ce5).
- design.md:554 marks the 45-paused stage-0 baseline explicitly as historical.
- .env.example said the Redis ACL password was the bootstrap trust root; it is
  CONFIG_ENCRYPTION_KEY (main.rs:88-91).

Docs, re-anchored after the main.rs shrink (6 refs shifted by -2).

GATE-P0 62 passed / 0 failed / 1 ignored; GATE-DOCS 4/4 clean
(228 line anchors, 211 section refs, 0 errors).
2026-09-28 22:33:27 +08:00
anonsaber c75338b305 docs: 修正 5 个漂移的行锚点(notify.rs 超时校验 411-414 → 491-494)
audit_anchors.py 只证明锚点可达,证明不了内容;本轮对 156 个唯一锚点
(150 个 .rs + 6 个 .js)做了逐条内容复核,抓到 2 处指向错误:

- reference.md §6.1:四个超时参数的「validated notify.rs:411/412/413/414」
  全部指向 notify.rs 的投递去重错误分支(error_response 的 4 行调用),
  而非校验谓词。真实位置在 491-494,原引用偏移恰好 80 行——是旧版本
  notify.rs 的残留锚点。
- reference.md §2.2:consent:ai:{chat_id} 的 EX 写为 state.rs:512,
  实际 .arg("EX") 在 511,512 是 TTL 取值行。

代码未改动,锚点基线 bfe0fd8 仍然成立,AGENTS.md 基线不 bump。
GATE-DOCS 仍 4/4 clean。
2026-09-28 22:04:58 +08:00
anonsaber a2297c21e8 tools: 固化文档校验器为 GATE-DOCS(此前只存在于 /tmp,保证不可复现)
四个校验器原先散落在 /tmp,本会话 /tmp 就被清过一次(validate_docs.py
中途消失),仓库里没有任何引用——「222 个行号锚点 0 error」这个结论在脚本
被清之后就无从再证,属于静默漂移。现固化进 scripts/docs_check/:

- validate_docs.py  代码块围栏闭合 / 行内反引号偶数 / 本地链接与 #锚点可达
- check_tables.py   表格表头、分隔行、数据行列数一致(行内代码里的 | 不计)
- audit_anchors.py  每个 foo.rs:N / foo.rs:N-M 锚点指向存在且非空的目标行
- check_sec_refs.py 每个 §N.N 交叉引用解析到归属文档的真实标题
- run_all.sh        驱动脚本,任一失败即 exit 1,仿 GATE-P0 的用法

改动说明(相对 /tmp 原版)
- ROOT 由硬编码绝对路径改为 Path(__file__).resolve().parents[2],干净 clone
  即可运行,无需改任何路径。四个文件已 diff 确认仅此一处差异。
- audit_anchors.py 加了 docstring 写明能力边界:只证明锚点可达,不证明该行
  内容与所述一致,无法发现「指向错行」的锚点。
- run_all.sh 用 grep -m1 '^===' 取摘要而非 head -1:validate_docs.py 摘要前
  带一个空行(原版自带瑕疵,保留原样以免偏离已验证版本),按位置取会取错。

双向验证:干净树 PASS/exit 0;注入 worker.rs:99999 与 §99.9 后分别被
audit_anchors 与 check_sec_refs 报出、exit 1;git checkout 还原后回到 PASS。

AGENTS.md 记录跑法与「可达 ≠ 准确」的区分。GATE-P0(cargo)不受影响,本次
无任何 src/ 改动。
2026-09-28 21:58:07 +08:00
anonsaber c2f6f32093 docs: roadmap 补全 XAUTOCLAIM 阈值公式因子,AGENTS 文档基线 bump
- roadmap.md:26「超时预算 × 安全系数 × 批大小」→ 写全真实公式
  (max_retries+1) × (jmap+telegram+llm 超时) × 2 × 批大小,并注明 300s 下限 /
  6h 上限与 reference.md §6.3 交叉引用;原来的压缩写法漏了 attempts 因子
- AGENTS.md 版本基线:文档 399b269(⑤)→ c746ead(锚点核验基线);代码仍 bfe0fd8。
  本次改动仅触两处文档文字、未引入任何新行号锚点,故 c746ead 仍是有效的
  锚点核验基线
- design.md §10.5 历史已修复清单里的「XAUTOCLAIM 空闲阈值按批大小缩放」按原样
  保留:它是当时的真实状态,非当前状态,不改写历史条目
2026-09-28 21:49:31 +08:00
anonsaber c746ead184 docs: 同步 /search 落地与 ⑥-a/⑥-b 后的断言、重锚 worker.rs/reference.md
⑥-d 文档同步(01a0e760-b6f5),无代码改动。

/search 落地(bfe0fd8)
- design.md 新增 §5.7「邮件搜索」:Filter::text + SearchSnippet/get、高亮降级、
  纯文本出站、失败映射、截断上限、正文级高亮在锁定版本做不到(含原因)
- design.md §2.3 接口表、§3.2 search_emails 方法行、§5.6 Intent 6 种、§10.5 待办收口
- reference.md §8「Not implemented」:删掉「/search 未实现 / SearchSnippet 零命中」
  两条已失效断言,改为「正文级高亮不可能」这一真实缺口
- reference.md §1.2 补 /search 触发词(仅前缀匹配,不授予授权、不写 Redis 键)
- AGENTS.md 阶段 5 标注搜索已完成

⑥-a/⑥-b 遗留的陈旧断言(本轮清掉)
- design.md §12.8 速率:429 从「不做专门的自动退避」改为按 parameters.retry_after
  退避(截断到 60s 预算,缺字段回退指数退避 250ms→4s)
- design.md §10.6 已知边界 + §9:XAUTOCLAIM 阈值从「批大小 × 300s」改为按运行配置
  推导,公式、300s 下限与 6h 上限指向 reference.md §6.3
- reference.md §6.3 重写阈值公式,新增 §6.4 搜索上限常量表

重锚
- worker.rs:569 → 613(help_message,⑥-c 插入后下移)
- AGENTS.md 版本基线 → bfe0fd8;retired.md 验证基线 → bfe0fd8;
  retired.md read_batch 锚点 343→384 / 379→425 / 376→394-401
- design.md §3.5 交叉引用 §12.9 → §12.8
- SAF-DEBUG-ALLOWLIST 保持「不改代码」原状,按产品决策未动
2026-09-28 21:45:05 +08:00
anonsaber bfe0fd88aa feat: Telegram /search 邮件搜索(email/query Filter + SearchSnippet/get 高亮)
⑥-c 落地代码缺口:/search <关键词>

实现
- JmapBackend::search_emails 加 trait 默认实现(mock 后端返回 InvalidRequest,
  真机后端用 Email/query + text Filter 取 ID,再 SearchSnippet/get 取高亮)
- JmapService::search_emails 暴露搜索入口,与 list_emails_page 共享超时/错误映射
- worker:Intent::Search + /search 分发分支 + /help 说明行

降级路径(不 panic、不回栈给用户)
- SearchSnippet/get 不支持(unknownMethod/timeout)→ 仍返回匹配 ID 列表,
  渲染降级为高亮片段暂不可用
- 无匹配 → 没有找到匹配「…」的邮件。
- JMAP 侧失败 → SearchReply::Retry,交由协调器 503 + Retry-After 重试
- 搜索关键词为空 → 引导提示,不发请求

安全边界
- subject/preview 只做展示:strip_mark_tags 剥 <mark> 高亮(大小写不敏感、
  未闭合标记丢弃,不泄漏原始标记),unescape_html_entities 单趟解码,
  先剥标签再解码实体以保留邮箱正文里字面 <mark>
- 无匹配 ID / 主题 / preview 分别有截断上限(120/120/160 字符)
- 每条命中带 email_id,可直接接 /summary <email_id>
- 中文关键词仅前缀匹配,避免帮我搜一下发票被劫持成搜索、或覆盖授权意图

测试 +7(55 → 62 passed, 0 failed, 1 ignored)
2026-09-28 21:09:06 +08:00
anonsaber 6c99ce51ab state: XAUTOCLAIM 空闲阈值改由运行超时配置推导,封顶 6h(01a0e75f-f9b1)
现状:read_batch 里 const SINGLE_EVENT_CEILING_MS = 300_000 是硬编码,
而真实单事件上限 = (max_retries+1) x (jmap+telegram+llm 三个 timeout),
运维把任一超时调大后实际耗时可能远超 300s → 空闲阈值欠保护,
多实例会把还在处理的条目提前认领,造成重复投递。

改动(ROADMAP 缺口③「多实例重复窗口」):
- single_event_ceiling_ms(config) 纯函数:(max_retries+1) x (jmap+telegram+llm) x 2
  (SAFETY),并用 .max(SINGLE_EVENT_CEILING_FLOOR_MS=300_000) 兜底,
  零值配置不会把窗口缩到 legacy 常量之下。
- reclaim_idle_threshold_ms(config, count) 纯函数:count.max(1) x 单事件上限,
  再 .min(RECLAIM_IDLE_THRESHOLD_CAP_MS = 6h) 防误调把恢复拖到天级。
- read_batch 调用 self.get_outbound_config()(trait 方法,同实现内已有,未改签名),
  Ok 走推导值,Err 回退 count x 300_000(不破坏投递主流程、不传播 Redis 错误)。

既有闸门不动:notify.rs 投递前查 delivery:committed、60s delivery:inflight 在途租约、
retry_or_dlq 3 次。本次只修阈值本身,性质保持「永不丢、只可能重复;单实例不受影响」。

:356-364 那段注释改写为描述真实机制(旧文「~300s 上限」已不准确),
未写入任何未经测试证明的断言。

测试:3 个纯函数单测覆盖 —— 默认配置 (15+10+30)x4x2=440s >= 300s floor、
大超时配置阈值更大、零值配置不缩到 floor 之下、荒唐超时被封顶 6h、count=0 用 1 事件兜底。
GATE-P0 全绿:fmt --check、check --locked、clippy --locked --all-targets -D warnings、
test --locked → 55 passed / 0 failed / 1 ignored。
2026-09-28 18:38:00 +08:00
anonsaber f4cae00adc telegram: 重试之间加指数退避,429 尊重服务端 retry_after(01a0e75f-f7e3)
现状:send_text 的 for _ in 0..=max_retries 重试循环在两次尝试之间零延迟,
任何 5xx/超时/429 都以最短间隔连续打 API,429 场景下反而更容易触发二次限流。

改动(ROADMAP 缺口②「Telegram 429 退避」):
- backoff_delay_ms(attempt: u8) 纯函数:起步 250ms、每轮 x2、单轮封顶 4s。
  调用方 sleep 只走这一个出口,便于单测直接断言序列而无需真实等待。
- retry_after_ms(body) 纯函数:解析 429 响应体 parameters.retry_after(秒),
  转毫秒后钳到 60s 上限;缺字段/非数字/非正/非有限值一律回退到指数退避。
- 总退避预算上限 60s:耗尽后 delay 归零不再 sleep,避免单次 send_text 长时间挂起。
- 429 单独分支,错误串改为 telegram request rate limited (429) 以便区分限流与一般失败。

语义不变:max_retries 仍 cap 5、每次请求仍受 telegram_timeout_ms 约束、
返回 Result<(), String>、错误串不泄漏 token。

测试:client() 通过 with_sleep_override 注入 no-op sleep,退避逻辑完整走通但不真实等待。
新增 3 个测试:429 端到端(mock 返回 parameters.retry_after=1,断言 2..=3 次请求)、
backoff 序列(250/500/1000/2000/4000 后封顶)、retry_after 解析与钳制。
GATE-P0 全绿:fmt --check、check --locked、clippy --locked --all-targets -D warnings、
test --locked → 52 passed / 0 failed / 1 ignored。
2026-09-28 18:13:08 +08:00
anonsaber 0ffe19cfe6 docs: README 中英双版补「保持 debug 关闭」的运维规则
用户结论:SAF-DEBUG-ALLOWLIST 那项不需要修,只要控制好不打开 debug 即可;
这条运维规则应写进面向用户的 README,不能只留在 deployment.md。

README.md / README.zh-CN.md(1:1)在既有「远程联调面」段各补两句:
- 唯一的运行规则就是不要打开——启动命令不加 `--debug`、环境变量不设
  `DEBUG_TOKEN`,缺任意一个 `/debug/*` 就不存在。
- 若确需为一次排障临时开启,先配好 chat 白名单——白名单为空时
  `POST /debug/notify` 的测试发送不受限到任何 chat。

无新链接、无新锚点、无表格,纯散文;`docs/deployment.md` §2.1 引用沿用段内
既有引用。代码未改动,gate 基线不变(afa6cec,49/0/1)。
文档校验器 0 error(exit 0)。
2026-09-28 17:18:59 +08:00
anonsaber 0522d6901d docs: AGENTS.md v3 基线文档哈希更正为 399b269(⑤ 收口提交)
e502c40 是这行基线写作时的文档 HEAD,但不含本轮 ⑤ 落地的 deployment §2.1、
design §7.6、README 联调段与全量锚点重锚——按它 checkout 看不到本轮文档状态,
具误导性。399b269 才是 ⑤ 的落地提交。

本次仅元数据修订,不改变任何行号锚点基线:行号锚点以代码 afa6cec 为准,
代码侧已冻结(reference.md:6 / retired.md:7 / roadmap.md:5 三处指向代码哈希,
不动)。§7 中 MOD-DEBUG、SAF-DEBUG-GATE 两处指向 docs/design.md §7.6 的
定义点追加保持保留:索引里查不到「为何默认关闭」的落点就失去意义。
2026-09-28 16:37:47 +08:00
anonsaber 399b26918e docs: ④ /debug/* 配套文档 + 全量锚点重锚 + AGENTS v3 版本基线(01a0e6de)
## 锚点重锚(afa6cec 之后)

④ 对 notify.rs 的编辑使一批 `notify.rs:N` 引用整体失效。全量重扫 8 个 md、
165 个 src 锚点,0 硬错误(越界 / 区间全空 / 单行指空行三类全查)。修正要点:
- reference.md(30+ 处):dedup/committed/inflight/ratelimit 键调用点、
  error_response 320-328、error_response_with_id 1087-1093、readiness 161、
  router 1275 + routes 1304-1318、worker 330、main.rs 40/50/34/78-81、
  NoopWorker 由 notify.rs 更正为 worker.rs:142-144、
  PROBE_TIMEOUT 73 与 tokio::join! 147、state.rs 639。
- deployment.md:149-152→227-230、159/172→239/252、index.js 40-55→45-60。
- retired.md:704→717、887-890→897-900、326→330、873→884。
- roadmap.md:95→175;design.md:main.rs 109→128。
- cloudflare-worker 侧 4 处 stale:ROUTE_METHODS 45-60、404 77-78、
  405 80-83、503 85-93。

## /debug/* 文档

- deployment.md 新增 §2.1:双因子门禁、7 条路由状态码表、/debug/config 字段清单、
  三条部署约束、生产建议;§5 env 表补 --debug / DEBUG_TOKEN。
- README.md / README.zh-CN.md 各加一段「远程联调面(可选,默认关闭)」,指向 §2.1。
- design.md 新增 §7.6「为何默认绝对关闭」:双因子挂载、404 而非 401
  (401 会泄漏路由存在性)、无默认开启回退、不在网关白名单内 fail-closed。

## 文档纠错(与任务书相反,已按源码核实)

- /debug/worker 是 GET 不是 POST(debug.rs:79)。
- /debug/config 不是「只出布尔」:明文回 jmap.{session_url,username}、
  telegram.chat_id、llm.{base_url,model} 与全部 timeout/retry,
  仅 5 个凭据字段出 *_configured。文档措辞改为「不含凭据,不是全脱敏」。
- 门禁位置更正:--debug 读 main.rs:99、DEBUG_TOKEN 读 main.rs:100-103、
  merge 在 notify.rs:1320-1321(无 DEBUG_BASE 常量,路径为字面量)。

## AGENTS.md

- 版本基线 v3(代码 afa6cec / 文档 e502c40),三处文档基线号 c5bc7b8→afa6cec。
- 新增 2 个 ID:SAF-DEBUG-ORIGIN-ONLY(不在网关 14 条白名单,Worker 404
  route not forwarded,只能直连 origin)、SAF-DEBUG-ALLOWLIST。
- §6 deployment.md 行、§7 六行 ID 定义点回填文档位置。

## 未决项登记

- roadmap.md 新增:SAF-DEBUG-ALLOWLIST —— debug.rs:232-235 的白名单判定
  「非空且不含」导致业务白名单为空时 /debug/notify 的 chat_id 不拦截。
  本轮不改代码(该面默认关闭且需 Bearer,空白名单下主产品本就接受任意 chat);
  若要收紧需改为「空白名单则拒绝」+ 补测试 + 跑 gate。

## 校验

文档校验器 0 error;负向对照断链/死锚点/奇数反引号/页内锚点 4 类注入全命中。
48 张 markdown 表 0 处单元格数不符。行数合计 2415。纯文档,未触碰 src/ web/
cloudflare-worker/。代码基线仍 afa6cec,gate 49 passed / 0 failed / 1 ignored。
2026-09-28 16:20:18 +08:00
anonsaber e502c40a9e docs: SPA 管理凭据 trust-root 同步(README/zh-CN/reference 三处措辞 + 锚点修正)
承接 800d6e0(SPA 凭据改用 CONFIG_ENCRYPTION_KEY):

- README.md §5、README.zh-CN.md §5:trust-root 段落补齐「配置密钥即管理密钥」
  的事实陈述,消除「Redis ACL 密码」的残留耦合叙述。
- docs/reference.md §3 Trust root:1:1 重写为配置密钥派生路径,移除不存在的
  "refresh loop" 叙述(实际为 refresh_business_config 单次调用 + guard)。
- docs/reference.md §3 锚点修正:expires_in 引用 notify.rs:700→:787
  (refresh_business_config 现位于 :717,expires_in 在 :787)。

交付方:文件规划助手(任务 01a0e379)。文档校验器 0 error,负向对照 4/4。
2026-09-28 15:08:04 +08:00
anonsaber afa6cec8ef debug: 新增 /debug/* 远程联调面,双因子门禁默认绝对关闭(01a0e319)
新增 src/debug.rs:/debug/ping、/config、/redis、/jmap、/telegram、/worker 六个只读
探针 + POST /debug/notify 一条测试出站消息。

SAF-DEBUG-GATE 双因子,缺任一即完全不挂载路由(请求落到通用 404,无"未配置即放行"
降级):
- 启动命令带 --debug(main.rs 最小 std::env::args 解析,未新增依赖)
- Secret 环境变量 DEBUG_TOKEN 非空

两层执行点:main.rs 推导 debug_token 并传入 router builder(None 则 debug_router 不被
merge);debug.rs 侧 auth_token 缺失时直接返回空 Router。

SAF-DEBUG-AUTH:每个 handler 先做 Authorization: Bearer DEBUG_TOKEN 常数时间比较
(复用 worker_authorized),失败 401,鉴权通过前不产生任何副作用。

探针只读侧全部复用现成实现,不另写一套:Redis 走 is_enabled,JMAP 走
probe_jmap_session,Telegram 走 probe_telegram_get_me,出站走唯一构造点
business_client(notify.rs 新增 pub(crate))到 send_text,/debug/notify 与
refresh_business_config 共用同一条客户端构造路径。

SAF-NO-SECRET-ECHO / SAF-LOG-PURITY:/debug/config 只回"是否配置"、计数与超时等
运行时参数,bot token / JMAP password / worker / webhook / reconcile token / LLM key
一律不出现在响应体;DEBUG_TOKEN 只在鉴权内部暴露,不回显、不入日志,日志仅一条
"debug endpoints enabled" 且不含令牌值。allowlist 用 chat_allowlist_snapshot 取生效值
(运行时业务配置优先、否则启动值),/debug/notify 显式 chat_id 不在 allowlist 则 403。

ARCH-LB-WORKER 白名单不含 /debug/*,该面仅供直连 origin 联调,公网入口不可达。

AGENTS.md 登记 4 个稳定 ID:MOD-DEBUG / SAF-DEBUG-GATE / SAF-DEBUG-AUTH /
REQ-DEBUG-ENDPOINTS(§2 第 19 条硬边界、§5 测试条、§7 索引 4 行)。

调试路由测试(4 项,notify.rs + debug.rs 内):Bearer 放行 200、缺失/错误令牌 401、
未提供 DEBUG_TOKEN 因子时 /debug/* 完全不注册而落 404、/debug/notify POST 未认证 401。

门禁四段全绿(rust:1-slim-bookworm):cargo fmt --all -- --check / cargo check --locked
/ cargo clippy --locked --all-targets -- -D warnings / cargo test --locked =
49 passed / 0 failed / 1 ignored。
2026-09-28 15:00:49 +08:00
anonsaber 800d6e0d00 notify: SPA 管理凭据改用 CONFIG_ENCRYPTION_KEY,解除与 Redis ACL 密码耦合
原实现把 SPA 管理凭据派生自 REDIS_URL 的 ACL 密码,并在 handler 侧用
`.is_empty()` 直接拒绝空凭据。托管 Redis 常见配置是 TLS-only 无密码,
那种情况下 `.is_empty()` 恒为真,管理界面永久 401 —— `POST /api/bootstrap`
与 `POST /api/admin/session` 双双不可用,无法完成初始化。

改为直接取 CONFIG_ENCRYPTION_KEY:它本就是启动必填 Secret(64 位 hex /
32 字节,见 config::encryption_key_from_env),启动时已校验,因此生产环境
不可能出现空凭据,handler 侧的空串守卫随之失效,一并移除。Redis ACL 密码
回归本职:只用于数据库连接。

- main.rs:env 只读一次(encryption_key_raw),admin_token 与已校验值不可能
  分叉;删除 Url::parse(redis_url).password() 分支。
- notify.rs:AppState 字段 bootstrap_token → admin_token(4 个构造点同步),
  两处 handler 删除空串守卫,bootstrap() 文档不再称 Redis ACL bootstrap。
- 测试更名 bootstrap_rejects_missing_admin_credential:断言语义不变(测试态
  空凭据收到 Bearer 仍 401),名称改为反映新语义。

门禁全绿:cargo fmt --check / check --locked / clippy --all-targets -D warnings
/ test --locked = 45 passed, 0 failed, 1 ignored。
2026-09-28 12:43:33 +08:00