Handoff: email-proposal Matrix review live (v0.1.0:91); bot role + whole-thread redaction

Durable updates after the email-proposal review session:
- AGENTS.md: roles admin/member -> admin/member/bot; add a Conventions entry on
  the bot role and the reach(role)-vs-autonomy(approval gate) principle.
- matrix-intake guide: rewrite the bridge section to final behavior (redact_thread
  whole-thread redaction, the Element 'show deleted' client-setting dependency for
  full clearing, the redact_resolved.py backfill tool, deploy gotchas).
- Current state rewritten lean (14->8 bullets); test count 27->30.
This commit is contained in:
Keysat
2026-06-18 12:51:46 -05:00
parent b2690c4342
commit ee6a4e52d2
2 changed files with 31 additions and 14 deletions
+23 -4
View File
@@ -108,17 +108,36 @@ the web panel. The CRM (box, stdlib, no matrix-nio) can't post to Matrix, so the
- **open** — pending, posted, not closed → the bot rebuilds its `event_id → proposal` routing map
from these on **every poll**, so replies still route **after a bot restart** (unlike intake's
in-memory-only store — the state lives CRM-side in `email_proposal_matrix`).
- **to_close** — decided on the **web** while a thread was open → the bot posts a "decided on the
web — thread closed" line and `POST .../{id}/matrix {closed:true}`.
- **to_close** — decided on the **web** while a thread was open → the bot clears it (see redaction
below) and `POST .../{id}/matrix {closed:true}`.
- **In-thread replies** (`bot.handle_email_reply`, `email_proposals.interpret`): `yes` →
`POST .../{id}/decide {decision:"approve", note}` (appends the note to the grid, source='matrix',
closes the thread atomically); `no` → dismiss; **anything else → NL revision of the note** via
local Qwen (`email_proposals.revise_note`, no Claude/scrub) — re-rendered for re-approval, so the
draft→approve gate holds. A no-op/empty revision re-prompts instead of saying "Updated".
- **Two surfaces, one source of truth.** Decide on the web → the bot announces + closes the thread;
- **Card formatting:** `email_proposals.render_card` frames every card/reply with a `RULE` dash line
top and bottom (`frame()`) so threads don't bleed together on mobile, and the note **names who
emailed whom** ("{teammate} emailed {investor}" / "{sender} emailed the team") rather than a bare
Sent/Received — the wording is built server-side in `propose_email_activity_notes`.
- **Decided threads are redacted, not just closed.** On any conclusive decision (Matrix or web) the
bot calls `redact_thread(root)`: redact the card, then scan recent history (`room_messages`,
`MessageDirection.back`) for that root's `m.thread` replies and redact those too — so a resolved
thread clears from the **threads view**, not only the timeline. **No confirmation is posted on
success** (the thread vanishing is the ack; a confirmation reply would keep the thread alive).
- **Needs the bot to hold a `redact`/moderator power level** in the review room — required to
redact the *human's* yes/no reply (its own card needs no power). Without it, the reply lingers.
- **Full clearing depends on a client setting:** redaction removes the events, but Element shows a
"Message deleted" placeholder by default — turn OFF "show removed/deleted messages" in Element and
both the main chat and the threads view clear completely. (Verified the intended UX 2026-06-18.)
- **One-time backfill:** `backend/matrix_intake/redact_resolved.py` (dry-run default; `--apply`)
clears threads decided *before* this shipped (already `closed`, so the poll's to_close never
touches them). Run on the Spark: `docker compose run --rm intake python -u
backend/matrix_intake/redact_resolved.py [--apply]`. It keeps cards still pending (CRM `open`)
and redacts every other card + its replies.
- **Two surfaces, one source of truth.** Decide on the web → the bot redacts + closes the thread;
decide on Matrix → the web panel polls `/api/activity/proposals` (~25s) and the card clears.
`email_proposal_matrix` (1:1 side row, migration `0003`) carries `event_id`/`posted_at`/`closed_at`;
a matrix decision sets `closed_at` in the same txn so it's never re-announced via `to_close`.
a matrix decision sets `closed_at` in the same txn so it's never re-processed via `to_close`.
- **Pure logic is `email_proposals.py`** (card render, reply grammar, note revision) — unit-tested
offline in `test_email_proposals.py`; the async poll/post wiring is in `bot.py` (live-smoke only).
- **Known minors (low-likelihood, ~5-person team):** if the CRM is unreachable *between* posting a