The previous round (88f0923) threaded a `backend_id` from `BACKEND_ID`/`HOSTNAME`
env vars into `AppState`. That required the operator to manually align each
instance's `BACKEND_ID` with the Cloudflare Worker's `BACKEND_ORIGINS_JSON`, which
is exactly the kind of manual cross-config sync that drifts.
The request's own `Host` header already carries the answer: the Worker load
balancer (`ARCH-LB-WORKER`) preserves the inbound `Host` while rewriting the target
to a backend origin (`joinUrl` builds `origin + path + query`), so `Host` is the
public address the LB routed this instance as. `backend_label_from_headers` reads
it per-request in `setup_status`, trims the default https/http port
(`example.com:443` -> `example.com`) so the label mirrors a bare origin, and returns
`None` when the request carries no usable `Host`.
This removes the startup env entirely — `backend_id_from_env`, `AppState::backend_id`,
all five router-builder `backend_id` parameters, the `.env.example` `BACKEND_ID`
entry, and the `BACKEND_ID` env documentation are all gone. Handlers still never
touch the environment: `Host` is request-scoped, not process-global, so there is no
test race (the `debug_surface_requested` rationale was about env, not headers).
Tests: `setup_status_reports_backend_label` now sends `Host: edge-1.example.com:443`
and asserts the label is the bare origin `edge-1.example.com` (port trimmed). A new
`setup_status_reports_null_backend_without_host` asserts `null` when no Host is sent.
GATE-P0 106 passed / 0 failed / 4 ignored; GATE-DOCS 6/6 clean (49 line-refs).
`/api/status` now echoes a `backend` field alongside `version`. The baked
`BUILD_VERSION` is identical across every instance built from the same image bath,
so it answers "did this deploy land?" but not "which origin answered?" when a
probe goes through the Cloudflare Worker load balancer (`ARCH-LB-WORKER`) — and the
Worker's `x-lb-backend` header is unreliable (it is silently dropped when the
response headers are immutable, e.g. cross-origin without CORS). Having the backend
self-report in the body removes that dependency entirely.
The value is a per-instance observability label read once at startup from
`BACKEND_ID` (falling back to `HOSTNAME`, which container runtimes set their
selves). It is not a connection secret and is never persisted (`C-REDIS-ONLY-STATE`).
`null` means neither env var was set — two such instances are indistinguishable by
this field by design.
Wiring keeps handlers off the environment (matching the `debug_surface_requested`
rationale: env is process-global and reading it inside a handler races tests):
`main` reads it once and threads `backend_id` through the router builders into
`AppState::backend_id`.
Also replaces the drift-prone `src/main.rs:<line>` anchors in design.md /
design.zh-CN.md with symbol references (`std::env::var` key names,
`LlmClient::with_runtime`), per the symbol-not-line rule; the insert had already
shifted them.
Tests: `setup_status_reports_backend_label` asserts the label round-trips into the
body. GATE-P0 105 passed / 0 failed / 4 ignored; GATE-DOCS 6/6 clean.
User feedback: any finite TTL causes push to silently stop working
eventually. Stalwart does not re-verify (RFC 8620 §7.2 sends the
verification request once at subscription creation). The key is
explicitly deleted by forget_push_subscription when the subscription
is retired, so no TTL is needed.
Changes:
- PUSH_SUBSCRIPTION_SECONDS: WEEK → 0 (never expires)
- remember_push_subscription: ttl_seconds=0 → SET without EX (Redis)
/ expiry=i64::MAX (MemoryState)
- ttl_contract_is_pinned test: assert 0
- docs: reference/opengaps/design updated to 'never expires'
Root cause of push notifications failing while cron works:
- Stalwart sends the verification request (RFC 8620 §7.2) once at
subscription creation; event pushes (§7.3) arrive without
verificationCode and rely on dedup_exists("push:subscription:{id}")
to confirm prior verification.
- TTL was 300s (5 min): key expired → every event push 401'd.
- Cron path bypasses this key entirely (polls /changes directly),
which is why cron-delivered emails still arrived.
Fix: align with PUSH_REGISTRATION_SECONDS (1 week). Stalwart does not
re-verify; the key must survive restarts and any gap between
subscription creation and events.
Also fixes: removed fictional push:subscription-code:{code} from
opengaps TTL table (no such key exists in code); corrected
reference.md caller from register_push→jmap_push verification path;
fixed 前→EX typo in reference.zh-CN.md; design.md updated with
fifth 'why' bullet and historical context.
The sentence explained why RUN_MODE doesn't exist — but if it doesn't
exist, there's nothing to explain. Removed from README (EN+ZH) §4 and
reference (EN+ZH) §5.1. Also fixed Chinese curly quotes to backticks
on the DEBUG_TOKEN row in reference.zh-CN.md §5.1.
Two changes requested by the user:
1. Fold the hand-holding quickstart tutorial into README §3 (both
languages). Delete docs/quickstart.md and docs/quickstart.zh-CN.md.
Remove them from all 5 validator DOCS lists, README §6 doc index,
and charter §7 boundary table. The tutorial now lives as
README §3.0–§3.11: prerequisites, create Telegram bot, find chat id,
get JMAP credentials, run backend, sign in, fill config, register
callbacks, enable processing, verify, schedule drain, troubleshoot.
2. Replace 'gateway'/'网关' with 'load balancer'/'负载均衡器' across
all markdown (README, docs/, AGENTS.md, cloudflare-worker/README).
Also fix 4 markdown anchor links whose heading slugs changed.
Also fix charter §8 '18 safe routes' → '19' (was missed in the
earlier reference.md fix).
GATE-DOCS 6/6 PASS (71 anchors, 727 path-refs, 455 sec-refs).
SAFE_ROUTES has 19 entries (backends.js), not 18. reference.md and
reference.zh-CN.md both said '18 entries'; both now say 19.
reference §4 said the /api/enabled and /api/admin/session calls are
'admin-session Bearer-auth'd'. The code says otherwise: config_authorized
(notify.rs:423) accepts the shared worker token OR a valid admin session,
and create_admin_session (notify.rs:1187) is gated by worker_authorized
against the admin credential CONFIG_ENCRYPTION_KEY — it is what mints the
session, so it cannot be session-auth'd. Both languages now state the two
distinct credentials and why adding /api/enabled opens no new credential
path.
deployment §10.8 said every request-bearing entry point, listing /healthz
and /api/*, calls refresh_business_config. The actual 10 call sites cover
8 distinct paths (/ready, /worker, /reconcile, /push/jmap, /webhook/tg,
/api/config, /api/business-config, /api/telegram/register-webhook). GET
/healthz is a static 200 "ok" and never refreshes; /api/status,
/api/enabled, /api/push/register, /api/push/disable and the
/api/admin/session pair do not either. Both languages now list the real
set and name the exclusions.
design.md §5.7 listed the search trigger prefixes as English
'search'/'find'/'retrieval'; worker.rs:689 accepts the Chinese prefixes
搜索/查找/检索 and the help text states English aliases are not supported.
The two user-facing strings were also mistranslated: the empty-query
guidance prompt is 请提供搜索关键词,例如:/search 发票 (worker.rs:623)
and the no-match reply is 没有找到匹配「<query>」的邮件。
(worker.rs:792). The degraded-rendering marker is likewise a Chinese
string. SEARCH_SUBJECT_MAX=120 / SEARCH_PREVIEW_MAX=160 /
SEARCH_LIMIT=10 verified unchanged.
design.md §5.6/§5.7 were machine-translated from Chinese back into
English and were broken: 'the first 300 if it fails. characters', a
stray closing ** mid-sentence, '(none) deny_unknown_fields)' with a
mismatched paren, and 5 zero-width-space artefacts. charter.md:168 had a
broken '**Tables, lists, or values may not be copied across documents. **'
and opengaps.md had 4. All ZWSPs removed from docs/.
deployment.zh-CN.md §10.5/§10.6 were thin mistranslations: 'Worker 域'
for 'Worker URL', the debug-token requirement dropped entirely, and a
mangled '两者都使用用于验证部署的可用和版本'. Rewritten against the EN.
Also replaced a worker.rs:227 line anchor with the MetadataWorker doc
comment; that drops the repo's line-refs from 72 to 71, so new text no
longer adds to the drift surface.
No code changes; GATE-DOCS 6/6 (71 anchors, 455 section refs, 727 path
refs).
The zh translation was generated by a machine translator that treated
backtick-delimited literals as prose. It therefore translated Redis
keys, function names, trigger words and status labels, which made the
documentation technically wrong rather than merely stylistically bad:
config:business -> 配置:业务
config:outbound -> 配置:出站
config:admin_session -> 配置:admin_session
consent:ai:{chat_id} -> 同意:ai:{chat_id}
delivery:inflight/committed
-> 交付:飞行中 / 交付:已提交
dedup:jmap:{account}:{email}
-> dedup:jmap:{帐户}:{电子邮件}
ratelimit:push-verify:* -> 速率限制:推送验证:*
push:subscription:* -> 推送:订阅:*
state:jmap:since -> 状态:jmap:自
lock:reconcile -> 锁定:协调
stalwart:*:dlq -> 坚定:*:dlq
set/get_business_config, set_enabled, is_enabled,
remember_push_subscription_id -> Chinese prose
Same damage class, three more forms:
- a typo: delivery:committed -> delivery:comfilled
- a case change that breaks a machine-readable code:
429 push_verify_rate_limited -> 429 Push_verify_rate_limited
- translated trigger words: 直到我取消 / 撤销授权 / 停止摘要 instead of
直到我撤销 / 撤销授权 / 停止摘要
Also repaired:
- six table headers 关键|TTL|作家|读者 -> 键|TTL|写入方|读取方
- a stray TTL number duplicated into the label column (3600 1小时)
- the AI-consent label row (直到截至 -> 直到撤销(最长365天))
- a dropped aside, 因此递增, which silently removed the reason why
increment, DLQ append and source ACK are atomic
- the 搜索/查找/检索 prefix list, where 检索 had been normalised to 搜索
Section 4 was rewritten rather than patched: the paragraphs carried a
dangling untranslated 'all:' and 'proxy', a dropped closing '**' on
**is forwarded**, 'two calls' rendered as 两个电话, and word order that
reversed the meaning of 'no second ingress is needed'.
Verification: headings and table row counts already matched the English
file exactly (6 section pairs, no structural drift), so this is a
content repair, not a re-sync.
No code changes; GATE-DOCS 6/6 (72 anchors, 455 section refs, 727 path
refs).
Reframe the Cloudflare Worker gateway across the entry points where a
reader forms an impression of it, in both languages:
- README §2 / §5 (en+zh): the gateway is optional and exists because
Cloudflare's Load Balancer product is not on the free plan. Drop the
comparison row that listed 'only the 19 safelisted paths' against
'every route' as if the safelist were a benefit; replace it with
backend reachability, which is identical either way, and move the
route-count fact to its own neutral row. Add one sentence to §5 that
the gateway is outside the secret boundary.
- deployment.md §10 (en+zh): purpose is load balancing, not protection;
no intent to hide or harden the backends, no access control at the
edge, the allowlist is not a firewall.
- cloudflare-worker README (en+zh): new 'why it exists' / 'why it is
not a security feature' block, plus a 'not a security layer' bullet.
Drop 'access-hiding capability' from the non-capabilities list, since
claiming invisibility as something we refuse to provide implies it is
the interesting axis.
- reference.md §4 / §9.2 (en+zh) and deployment.md zh §2: stop saying
bootstrap and /debug/* are 'not exposed' / 'kept off the public path'.
They are simply not in the route set, which is why a request to the
Worker never reaches them — reachable at the backend's own address
either way.
- charter ARCH-LB-WORKER (en+zh): 'only external entrance' -> 'only
*registered* entrance', which is what C-LB-SINGLE-REG-URL actually
says, and backends stay directly reachable.
SAF-LB-PASSTHRU already records the security-side invariant (no auth
or body rewriting, backend may be directly connected to the public
network), so no new stable ID is needed. No code changes; GATE-DOCS
6/6.
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.
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.