0141 — Held messages: per-module reviewers on the customer side, and one staff-reply door
- Status: Accepted
- Date: 2026-10-08
- Deciders: Gera (ruling, 2026-10-08, decision b91487325 and chat). Gera answered alone.
- Supersedes: Fede's standing rule that customers never review held messages (2026-09-19, the
review-mode comment in
CustomerDetailClient.tsx), and the "exactly one reviewer, for every customer" premise ofREVIEW_GATE_MENTION(src/lib/domain/organizations/review-gate-mention.ts). - Related: ADR-0139 (the door OUT: one Off · Supervised · Live switch per module; §9 is what holds a message)
- Plan: customer-approvals-plan
Context
Supervised (ADR-0139 §9) holds a module's outbound messages until a person approves them. Today only PropFlow approves: every hold posts to our Slack and leads with one hard-coded mention (Fede), and the review mode switch's own comment records Fede's rule that the customer's people never see or operate it. Customers asked to approve their own held messages, and a module's right reviewer differs by module (a property manager for Maintenance, a leasing lead for Leasing, a handyman who is already on file as a vendor).
Replies from a customer's team come back through several doors (escalation decision requests,
team-notification emails, Slack threads, texts), and each decides on its own who wrote, what they
are answering and what they want. The same reply means different things depending on where it
lands: "why?" rejects a held text in Slack but rewrites a held prospect email, and replies to the
weekly owner report and the team notices are dropped with no alert. Two defects were confirmed by
hand on main: team-notify saves SendGrid's x-message-id instead of our own Message-ID, and its
reply key travels only as a SendGrid custom arg, never on the email itself.
Decision
1. Reviewers are chosen per module, per company. On the staff customers page each module row carries its own reviewer list. A reviewer is someone on that company's team (staff) or one of its vendors. A typed address is accepted only when it resolves to company staff or a company vendor; an address that is neither is refused by the route that saves the list, and nothing is stored. Decided (Gera, 2026-10-09): staff and vendors only for now. An address outside the company's team and vendor list cannot be a reviewer, and there is no one-time-link or bearer approval path.
2. No new switch. The Off · Supervised · Live switch per module (ADR-0139) is unchanged. Picking reviewers for a module is what sends that module's held messages to them as well as to our Slack. A module with no reviewers behaves exactly as before this ADR.
Which holds reach them. A held message goes to a module's reviewers when the hold BELONGS to
that module, whatever held it: a module's Supervised setting, or the company-wide review mode
(reviewOutboundEmails, which also holds Clara's prospect emails). A hold's module is the one its
source names (the held-card SUPERVISED_BY map) or, for Clara's replies, the module the reply
named. A hold that belongs to no module (a team ask held by review mode: a decision request, a nag,
a collections owner alert) reaches no customer reviewer; it stays PropFlow's alone.
3. Reach and precedence. A module's reviewers see everything held for that module at that company's buildings. The first approve or reject wins (the existing first-writer-wins claim on the hold row); a later answer changes nothing and is told so.
4. The email to a reviewer is never itself held. It is an always-on staff kind (the same family
as team_alert), carries who the held message is to, the draft, why it was held and a link to the
held-message page.
5. One staff-reply door in. Every reply from a customer's team or owner passes the same four steps before anything else reads it:
- Who is this? One identity check (staff, vendor, owner).
- What are they answering? Everything we send to staff carries a tag, and the reply carries it
back: by email a
+rk_plus-address and anX-PropFlow-Reply-Keyheader on the email itself, with our own Message-ID saved for In-Reply-To matching; by Slack, the thread; by text, the last message to that number (texts have no threading, and we say so rather than pretend). - What do they want? Approve, reject, change it, why, a question, stop. An unclear reply is answered with a question. It is never treated as a reject.
- Do it. One handler per want, the same for every channel. A reviewer's approve or reject goes
through
approveEscalationHold/rejectEscalationHold(or the held-text twins) with the reviewer named as the actor.
A known team member or owner whose tagged reply matches nothing alerts a person. Nothing is dropped silently.
6. Build order. Customer approvals are the first thing through the door: email only, approve and reject only (part 1). Team-notify's reply handling moves behind it and its two defects are fixed next (part 2). Slack and text replies, owners as a known kind of person everywhere, and one "who is this?" check follow (part 3). Asking for changes and asking Clara why ride the same door later.
What violates this ADR: a new staff-bound send with no reply tag; a held message approved, rejected or rewritten by anything other than the door or the existing staff page and Slack card; an unclear reply resolved as a reject; a reviewer address saved that resolves to neither staff nor a vendor.
Consequences
- The held-message page and its approve/reject writer must accept a chosen reviewer, scoped to the reviewer's company, its buildings and the module, not only a company administrator or PropFlow staff.
- The Slack mention stays for PropFlow's own review; a customer reviewer is an addition, not a replacement. Our Slack card names who approved.
- A reviewer email that cannot be sent never blocks the hold: the hold row and the Slack post are saved first, as today.
- Two guards keep it one door: a check that every message to a customer's team or owner carries a tag (exceptions in a short reviewed list), and a check that only the door may resolve a held message. Both are follow-up work, not part 1.
Alternatives considered
- A new "customer review" switch per module. Rejected by Gera: a module already has one switch, and the reviewer list itself says whether customers review.
- Any typed email may approve (a one-time link). Rejected by Gera, 2026-10-09 ("no, staff and vendors only for now"): an approval sends a real message under the company's name.
- Fix each existing reply door in place. Rejected: five doors drift apart, and a fix in one never reaches the others. One door makes a fix land once.