Commit Graph

10 Commits

Author SHA1 Message Date
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
anonsaber 166b77eb34 docs: stop maintaining source line numbers in reference.md
`docs/reference.md` is the single source of truth and gets touched on every API
change, yet 40 of its 56 `notify.rs` anchors had drifted onto a closing brace,
a doc comment, or the wrong symbol - including one whole line pointing at
`revoke_admin_session` where a jmap dedup key is claimed - while
`scripts/docs_check/audit_anchors.py` reported every one of them clean. Its own
docstring says it only checks that the target line exists and is non-blank.

Remove the anchors rather than correcting them: fixing 40 numbers creates a
treadmill that re-drifts on the next unrelated edit to the same file, and the
file's own convention already called the line number "not an identity". Symbol
names are kept everywhere, which is what the gates and the readers need.

`docs/deployment.md` keeps its line numbers - it is an operator runbook read
mid-incident - and the five anchors that were wrong there are corrected.

docs/reference.md
- Drop the false "Verified against commit b2dbe7c" header claim; the anchors
  never matched that commit. Replace it with the design decision and the
  reason, and drop the "Only this file carries line numbers" convention that
  `deployment.md` was already violating.
- Remove all `*.rs:NNN` and bare `:NNN` anchors, keeping symbol names.
- Fix a wrong fact the anchors were hiding: the session issued by
  `POST /api/admin/session` is a fresh CSPRNG token generated per call, not a
  "fixed 32-byte hex secret derived from admin_token". The anchor pointed at
  `bootstrap`'s body.

docs/deployment.md
- `retry_or_dlq` call site 481 -> 479; `lock:reconcile` acquire 289 -> 290
  (twice); heartbeat `renew_lock` 302 -> 303 (twice).

AGENTS.md
- Record the convention: symbol names in docs, line numbers only in
  `docs/deployment.md`, plus the audit script's limits so nobody reads its
  output as "the anchors are correct".

scripts/docs_check/audit_anchor_semantics.py (new)
- Independent script, not wired into `run_all.sh`, always `exit 0`, prints
  candidates only. It flags an anchor whose target line has no relation to the
  symbol the doc claims. Candidates go 20 -> 5 and `reference.md` disappears
  from the list entirely. It cannot catch a one- or two-line off-by-one - that
  still needs a human - and a bare `:NNN` shorthand is not a matchable anchor.

Gates: GATE-DOCS 6/6 clean (ANCHOR AUDIT 324 -> 129 refs, 0 errors).
`src/` is untouched, so the test baseline stands at 86 passed / 0 failed /
2 ignored.
2026-09-30 02:02:34 +08:00
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 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 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