ADR-0125 — A past-due reminder reaches a resident only after a person approves it
- Status: Accepted — §4's clock clause SUPERSEDED by ADR-0129 (2026-08-19). Everything else here stands.
- Date: 2026-08-06
- Supersedes: the
'bake' | 'live'send mode in ADR-0112's 2026-07-24 and 2026-08-05 addenda (collections/send-mode.ts, now deleted). - Related: ADR-0112 (the collections lane), ADR-0117 (the compliance
harness), ADR-0092 (suppression), ADR-0119 (outbound records), ADR-0089
(language-aware outbound). The renewal-correction review queue
(
renewal-change-reviews) is the closest existing sibling — same capture → human decision → released effect shape.
Context
The collections lane composes past-due reminders for every delinquent resident, applies a long stack of stops, and — until now — decided whether to text them by reading a module-level constant:
const MODE: CollectionsSendMode = 'bake'; // change to 'live' to start texting
That was a deliberate design (ADR-0112, 2026-07-24): visible, reviewed, one
line, no environment variable that could silently flip. It ran that way for nine
days and produced 60 captured reminders on propflowai.co/review, which is
exactly what it was for.
The question it left open is the one that got answered here. Gera, 2026-08-06, verbatim:
"collections should be on, but all the messages or potential texts should go to the review page as a human blocked gate. So we aren't sending them out yet. Unless I click approve, then it would send it out. But it has to go through this hard-required review gate."
Two things in that are not the previous design. Collections is ON — the cadence is meant to run, for real properties, deciding real recipients and composing real copy. And the gate is hard — not a setting whose value happens to be "don't send", but a structure in which sending without an approval is not a thing that can happen.
The open draft PR #5458 proposed the other reading: flip MODE to 'live' and
let the lane text Camellia residents. That premise is now wrong, and the PR is
closed rather than reshaped — its entire content was the one-line flip this ADR
deletes.
Decision
The cadence has no outbound path. Approving a captured reminder on /review
is the only thing in the codebase that texts a past-due resident.
1. The cadence composes and queues; it does not send
runCollectionsChaseTouch runs unchanged through every existing gate — the
operator brake, the ledger re-read, the tenancy-exit stop, the PMS account
flags, the deterministic legality assessment, the tenant-reply stop, ADR-0092
suppression, the cross-path cooldown, the property-number check, the LLM legal
audit of the composed copy, and the at-most-once send claim. Its terminal step
then writes a CollectionsDunReview row with status: 'pending' and returns.
The 'live' branch is deleted, and so are the imports that made it possible:
the module no longer imports a dispatcher, a conversation-record resolver, or
anything else that can reach a carrier. There is no expression in that file to
reach, guarded or otherwise.
2. The gate is a type, not a flag
The one module that can text — collections/approved-dun-send-ops.ts — takes an
ApprovedCollectionsDun as its first parameter. That type carries a
unique symbol declared and not exported by collections/approved-dun-send.ts,
so no other file in the codebase can name the key, and therefore no object
literal anywhere else satisfies the type. Calling the sender with a plain review
row is a compile error.
The only expression that produces an ApprovedCollectionsDun is
claimCollectionsDunForSend, whose first write is a DynamoDB conditional update
of status = 'pending' → 'approved'. No claim, no value; no value, no call.
This is the same instrument ADR-0089 uses for ResolvedLanguage (a bare 'en'
at a send site is a compile error, not a code-review miss), and ADR-0079 for the
writer stamp. It is chosen here for the same reason: the property we want is
"you cannot write the mistake", and a boolean cannot express that.
A runtime assertion (assertApprovedForSend) backs it up for the caller who
defeats the type with a cast. The two are deliberately redundant — one makes the
mistake unwritable, the other makes it loud if someone writes it anyway.
3. What the claim also buys
Because a row leaves pending exactly once, under a condition expression:
- two reviewers clicking approve at the same instant cannot both send — the
loser gets
falseand never reaches the sender; - a row already at
sentis notpending, so re-approving an already-texted reminder is impossible rather than merely discouraged; - the capture path uses the matching non-resurrecting conditional write
(
attribute_not_exists(SK) OR status = 'pending'), so a Temporal retry cannot stamp a decided row back topendingand re-open it for a second send. That defect — a whole-item put resetting a decision past a 409 — is one this repo has shipped before, in the renewal-correction lane; it is not reintroduced.
4. Staleness — the rule, stated
A captured reminder may be approved for 48 hours; past that it is refused as stale. Regardless of age, the facts it asserts are re-read live at approval time and the send is refused if they no longer hold.
Both halves are needed. The window catches what we do not re-read (has a newer touch superseded this one? is this still the right cadence position?); the re-check catches what we do. The window is 48h because the cadence fires roughly daily against a re-read ledger, so a capture older than two touches has already been superseded — and because "I looked at this yesterday and it was right" is a statement a person can honestly make, where "I looked at this last week" is not.
The re-check refuses on: a cleared or sub-one-month balance; a balance that
merely MOVED (the copy quotes a dollar figure, and a partial payment makes that
figure wrong); an exiting tenancy; the in_collections / certified_funds_only
PMS flags; an ADR-0092 opt-out; a changed language preference (the body is frozen
copy); outside 8am–9pm on the PROPERTY's clock (§4 as originally written — briefly superseded by ADR-0129's resident clock, restored 2026-08-25); and an
unreadable ledger, flag feed or suppression list — all of which fail
closed.
⚠️ SUPERSEDED CLAUSE. This ADR originally read "outside 8am–9pm on the property's clock", and that was the wrong standard: TCPA measures the window where the CALLED PARTY is. The gate also rested on
Property.timezone, a field with no writer in application code, one??away from a hardcodedAmerica/Chicago. (It was NOT firing — all 17 prod rows carry a real zone, checked 2026-08-19 — so no resident is known to have been contacted outside their own window. The latent gate, not an observed skew, is the defect.) ADR-0129 replaces it: the window is resolved from the recipient's own phone number, with NO fallback zone, and a number that does not establish a zone refuses under a newrecipient_clock_unresolvedreason rather than sending on a guess. Gera settled it on 2026-08-19 (decision pagecollections-three-phases, question d4). The refusal list above is otherwise unchanged.
Refusals split two ways, and the split decides what the row becomes:
- Retryable (a read blip, a missing phone, the wrong time of day) — the row
is left
pendingand the reviewer is told why. Burning a real reminder to a terminal state over a DDB blip would be the worse error. - Terminal (they paid, the amount moved, they opted out, it aged out) — the
row is claimed straight to
send_refusedwith the reason, so it stops looking actionable.
The 60 backfilled rows are refused permanently as historical_capture: they
describe touches that already ran on permanently-consumed claims.
5. Sandbox properties are a separate assertion
Property.isTest (Yale Station 25's sandbox, The Willows) resolves
simulated_only in collections/send-policy.ts, and the send path refuses those
before it touches the row at all — with a distinct reason, stated separately in
the code and in the UI. This is not the approval gate: it is not "nobody
approved it", it is "this property can never text a phone, whatever anybody
approves". An unresolvable property resolves the same way.
6. The record tells the truth
CollectionsDunReviewStatus is now
pending | approved | sent | send_failed | send_refused | rejected.
sent is written only alongside sentMessageId — the provider's own id.
A delivery that returns no id records send_failed, because a sent row with no
id is a claim nobody can check. A dispatcher throw records send_failed too: an
unknown carrier outcome is never "sent".
Consequences
- Collections runs for every real property from the moment this merges. Nothing reaches a resident until someone clicks approve.
- The
/reviewqueue is now a work queue, not an evidence log. Its ever-open?since=window (opened when only the closed bake period wrote rows) now covers a growing set; the fix when it gets heavy is a status-scoped index, never silently dropping pending work out of the window. - The tenant-page collections chronology no longer takes a mode flag. It decides whether to caveat a closed cadence from an observed fact: a dun row on the thread exists only when an approved send wrote there.
- The
collectionsHaltbrake is unchanged and still fleet-wide, but its meaning narrows: pulling it stops NEW reminders appearing for approval; it does not recall one a person has already approved. - There is no longer any setting, anywhere, whose value decides whether
collections texts a resident. Re-introducing one would be a reversal of this
ADR, and
src/__tests__/collections-approval-gate.drift.test.tsis written to make that reversal loud rather than quiet.
Alternatives considered
Keep MODE and add an approval step on top. Rejected: the flip would still
exist, and one line would still turn the machine back into the sender. Gera's
word was "hard-required"; a gate you can step around is not that.
Signal a Temporal workflow on approval instead of sending inline. Rejected
for now as more machinery than the effect needs — the renewal-correction lane
already releases an irreversible PMS write inline from its decision
route, and this is the smaller effect of the two. If the outbound leg ever grows
slow enough to threaten the function budget, the seam to move is
ApprovedDunSendOps.dispatchApprovedDun, which is already injected.
Approve-all / bulk approve. Deliberately not built. The gate's value is that a person read the message; a button that approves forty at once is the flag again, wearing a different hat.