ADR-0125 — A past-due reminder reaches a resident only after a person approves it

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:

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 hardcoded America/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 new recipient_clock_unresolved reason rather than sending on a guess. Gera settled it on 2026-08-19 (decision page collections-three-phases, question d4). The refusal list above is otherwise unchanged.

Refusals split two ways, and the split decides what the row becomes:

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

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.