0112 — Tenant collections outreach: dun a past-due tenant on a durable, ledger-gated cadence
Relationship to ADR-0118 (collections-ai-pilot): the broader pilot program — Phase 0 language preference, portal-payer cohort strategy and portal-flag exclusions, promise-to-pay, measurement/kill criteria, counsel sign-off gates, 10DLC registration — is ADR-0118 (authored in parallel under the number 0112, renumbered 2026-07-28). THIS ADR is authoritative for the shipped lane wherever the two disagree.
- Status: Accepted (Gera signed off on all five decisions 2026-07-24 — see §3.7). REVISED 2026-07-24 (same day, post-merge): the two-factor send gate (Q1) was ripped out and replaced with a capture-only BAKE mode + a manual one-line flip — see the first Addendum. Where §3.7 Q1 / §3.8 Gate 0 / §2 describe that gate, the first Addendum supersedes them (and the second Addendum, 2026-07-28, adds the operator emergency brake as the new gate 0).
⚠️ SUPERSEDED IN PART BY ADR-0125 (2026-08-06): the
'bake' | 'live'send mode is GONE. Everywhere below that describes a mode, aMODEconstant,collectionsSendMode(),send-mode.ts, or a "one-line reviewed flip to live", read ADR-0125 instead. The lane now composes every reminder for every property and writes it to/review; a person approving it there is the only thing that texts a resident, and that is enforced structurally (the cadence imports no dispatcher; the sender takes a value only an approval claim can mint). Everything else in this ADR — the cadence, the stop stack, the copy, the enrollment threshold, the emergency brake — stands unchanged.
- Date: 2026-07-24
- Deciders: Gera (gate shape + copy tone + enrollment threshold + reply handling), Fede
- Origin: The follow-ups program (2026-07-20 Camellia launch review — "everything outbound should be understood into a path").
tenant.collections.delinquentis the lastplannedrow insrc/lib/domain/automation/followup-registry.ts. Delinquency data already feeds renewal eligibility and the owner report, but nothing ever messages a past-due tenant. This is the final lane of the program.
1. Context
What exists today
- The read-model is done and trustworthy.
getDelinquencyProfile(args)(src/lib/domain/leasing/delinquency/index.ts) returns aDelinquencyProfile { asOf, source: 'pms.charge_detail', balanceMonths, pastDueRent, latePayments, monthlyRent, isPastDue }computed from AppFoliocharge_detailvia the renewal decision-engine kernel (fetchRenewalFeatures). It is the single source of truth for "how delinquent is this tenant" — born precisely because three callers once disagreed (snapshot-counting overstated Merrit Notreal #118 as "8 of 9 months delinquent"). It returnsnullon any uncertainty (missing PMS ids,monthlyRent <= 0, no org, upstream throw); callers must treatnullas "unknown", never "not delinquent". Snapshot-counting is retired and must never be resurrected. - Exactly one production consumer: the tenant AI-summary route (
src/app/api/tenants/[id]/summary/route.ts) builds adelinquencyLineand injects it into Clara's summary prompt. No outbound path reads it. - No trigger event exists. Delinquency is computed on read, not materialized on a state change — there is no "tenant went delinquent" webhook. So enrollment needs a scan, not an event hook.
The forces
- Tenant-facing money messaging is the most compliance- and tone-sensitive lane in the program. PropFlow messages as the landlord's first-party agent (not a third-party debt collector under the FDCPA), but state notice rules and FHA-adjacent tone still apply. Copy must be plain, warm, non-threatening, with every dollar figure sourced from
DelinquencyProfile(hot rule 1 — no fabricated numbers). - The program's wiring pattern is proven 6× (prospect ×4, vendor ×2, team ×1). This lane reuses it verbatim: a durable Temporal workflow owns the timers, a send activity runs the full pre-send gate stack, the registry row flips to
configurable, and a live-harness receipt on an isolated queue proves it end-to-end. - Hot rule 13 prefers routing/scoping over on/off arms — except the "genuinely dangerous, explicitly-requested" class (renewal + holdover sends). Autonomous collections is the strongest candidate in the entire program for that exception. Ship-dark-by-default is the right posture even though most lanes ship live.
2. Decision (summary)
Build a new dedicated durable Temporal workflow collectionsChaseWorkflow (domain-named per the Temporal naming standard — collections is a business domain, not a persona), enrolled by a Schedule-gated daily cohort walker (collectionsCohortWalker, the renewal-cohort-walker template — never a cron/sweeper), that dun-nudges a past-due tenant on a configurable cadence and stops on the ledger itself — getDelinquencyProfile re-computed at each fire; when isPastDue flips false (or the balance drops below the enrollment threshold) the cadence ends because a real writer (the tenant's payment landing in AppFolio charge_detail) moved the number.
The lane ships disarmed behind a fail-closed gate (shape is Gera's call — §3.4/§3.7) and messages nothing to a real tenant until explicitly armed. Copy is plain-English, first-party, real-numbers-only (tone is Gera's call — §3.6/§3.7). A tenant reply mid-cadence pauses the dun and escalates to the PM (recommended — §3.5/§3.7) rather than continuing to chase.
No new persisted entity. (SUPERSEDED for terminal outcomes — see the 2026-07-29 addendum: the durable per-tenant CHASE RECEIPT persists chase outcomes across runs; within-run state remains workflow state.) Reminder state lives in durable Temporal workflow state (chasesSent, phase) exactly like every sibling chase; sends are recorded on the tenant's Conversation via the existing recordOutreachConversation. This keeps the lane free of the entity-classification drift guard and holds it to one concern.
3. Decision detail
3.1 Host workflow — a NEW collectionsChaseWorkflow
Mirrors the vendor-chase decision (PR #4426): a clean, isolated workflow, not an extension of an existing one (renewal/maintenance-comms are load-bearing, differently-keyed, and carry no reuse gain). Workers co-tenant one Fargate process via buildRegistry() in services/renewal-worker/index.ts, so a new queue/worker is one import + one array entry — no separate deploy.
- Queue
propflow-collections-chase(workers/collections-chase-queue-name.tsleaf +-worker.ts). - Workflow
collectionsChaseWorkflow,COLLECTIONS_CHASE_WORKFLOW_TYPE = 'collectionsChaseWorkflow', fileworkflows/collections-chase-workflow.ts, exported fromworkflows/index.ts. - Workflow id
collections-chase-<tenantOccupancyId>— one live cadence per tenant (a tenant has one delinquency state). Started withWorkflowIdConflictPolicy.USE_EXISTINGso the daily re-scan of a still-delinquent tenant is a no-op for a running cadence;WorkflowIdReusePolicyNOTREJECT_DUPLICATEso a tenant who pays, then later goes delinquent again, can legitimately re-enroll. - Input (PII boundary, ADR-0026): ids/enums/counts only —
{ tenantOccupancyId, propertyId, personId, leaseId, touchesOverride? }. No balances, no phone, no name in workflow history. The past-due dollar figure and the tenant phone resolve inside the send activity (viagetDelinquencyProfile+ spine phone claim), never crossing the activity boundary as input. A balance in workflow history would be a leak-class review finding. - No
patched()on v1 — a brand-new workflow has zero in-flight histories (same reasoning as vendor-chase / tour Phase-2). The replay corpus verifies determinism; every FUTURE timer change shipspatched()-gated. Documented in the file header. - Short-lived: bounded by the cadence (≤8 touches / ≤31 days per the shared
validateCadenceTouchesceiling), so no Continue-As-New.
3.2 Enrollment — a Schedule-gated daily cohort walker
There is no delinquency event, so enrollment is a scan, modeled exactly on renewal-cohort-walker.ts + cohort-walker-workflow.ts (a thin Temporal-Schedule-driven proxy to a processCollectionsWalkActivity). Not a cron — timers and scans live in Temporal. Gated solely by its Temporal Schedule's paused/unpaused state (registered PAUSED via scripts/temporal/upsert-collections-walker-schedule.ts).
Per property (skipping properties the gate disarms early, so a disarmed fleet does near-zero work) → getActiveTenants → for each tenant apply the exclusion ladder (copied from classifyTenant, precedence order), then the collectibility threshold:
occ.status === 'former'→archived(already moved out)lease.evictionPending === true→eviction(legal track owns it — never dun someone in eviction)lease.lastObservedNoticeAtset →ntv(on notice — turnover owns it)lease.leaseMoveOutDateset /lease.archivedAtset →movingOut- profile
null→unknownLedger(fail-closed — never enroll on an unreadable ledger) !profile.isPastDue→current- below the collectibility threshold (§3.3) →
belowThreshold - else →
eligible→ startcollectionsChaseWorkflow(idempotent viaUSE_EXISTING)
dryRun runs selection + profile compute but starts no workflow and writes nothing (the walker's own dry-run, separate from the send-gate arm — same two-layer shape as the renewal walker). The walk is bounded by the outer workflowExecutionTimeout and isolates errors per-property + per-tenant.
Amendment 2026-07-28 — the property-level skip is isTest, not the arm. The parenthetical above ("skipping properties the gate disarms early") describes the per-property arm that the Addendum below removed; there is no arm to skip on. The one property-level exclusion is now Property.isTest: bench properties are dropped from the unattended scan before their tenants are read, counted as propertiesExcludedTest, and surfaced in the run summary rather than skipped silently. Rationale: fixture tenants carry fixture ledgers that are past-due by construction, so the daily scan re-enrolled them forever and their bake captures landed in the same #alerts stream the operator judges the lane by — the first live walk (2026-07-28) enrolled bench fixtures alongside 11 real Camellia tenants. Property.isTest is the right instrument rather than an id list: a read-only prod scan that day found it set on exactly the two benches (appfolio-45, yale-sandbox) of 17 properties, whereas isTestProperty()'s hardcoded list holds only appfolio-45 and would have kept enrolling the Yale sandbox. An explicit forcePropertyId overrides the skip, so the lane stays exercisable against the bench.
3.3 Enrollment threshold — "collectible", not "a dollar short"
isPastDue alone (balanceMonths > 0) is too twitchy — a tenant $15 short or two days late would be dunned. The threshold reads off DelinquencyProfile (no new computation):
- Recommended default:
balanceMonths >= 1— the tenant is at least a full month behind. Unambiguously collectible, avoids nickel-and-dime dunning, trivially explainable to a customer ("we message tenants who are a full month or more behind on rent"). - Alternatives offered to Gera (§3.7 Q2): a dollar floor (
pastDueRent >= $X), or a softerbalanceMonths >= 0.5(half a month).
The threshold constant lives in code (COLLECTIONS_MIN_BALANCE_MONTHS), not source-baked per-property (multi-tenant rule); a per-property override can move to a PROPERTY_CONFIG# row later if wanted.
3.4 STOP authority — the ledger, plus the tenancy-exit signals
The stop is a real, writer-backed signal re-read at every fire (fail-closed pre-send re-check, the ADR-0104 discipline). The send activity re-computes getDelinquencyProfile and:
profile.isPastDue === falseORprofile.balanceMonths < COLLECTIONS_MIN_BALANCE_MONTHS→ STOP (paid/caught_up). This is the ledger — the tenant paid, AppFoliocharge_detailreflects it, the number moved. No invented "resolved" flag.profile === null(unknown ledger) → skip this touch, do not send (never dun on an unreadable ledger), cadence proceeds to the next touch. Repeated unknown simply exhausts the cadence harmlessly.lease.evictionPending/lease.lastObservedNoticeAt/lease.leaseMoveOutDate/lease.archivedAt/occ.status === 'former'→ STOP (exiting) — the tenancy is leaving; legal/turnover owns it, dunning would be wrong.- ADR-0092 suppression (
checkSuppression(phone, 'sms', 'outreach')) revoked → STOP/skip (the tenant said "stop texting me"). - Tenant reply mid-cadence → pause + PM escalation (recommended, §3.5).
Because the pre-send re-check runs before every send, a tenant who pays between touches is never dunned again even if enrollment already scheduled the touch — the ledger is authoritative at fire time, not at enrollment time.
3.5 Tenant reply handling — pause and escalate to the PM
A tenant who replies to a collections message ("I paid yesterday" / "I lost my job, can we do a plan?" / "this is wrong") must not keep getting dunned by an automated cadence. Conversation.status cannot be the signal (conversation-manager.ts:1548 auto-reopens any non-active thread on the next inbound — the same trap the team lane hit). The signal is a new inbound tenant message on the conversation after enrollment: at each fire the activity checks for an inbound (role: 'user') message newer than the cadence's enrolledAt; if present, it stops the cadence and raises a PM escalation (the pm_forward-style notification / bake-alert seam) so a human handles the human. This is cheap to design in now and expensive to retrofit; it is the recommended default (Gera confirms in §3.7 Q4).
Rationale: collections is exactly the lane where a tenant reply is high-signal (payment dispute, hardship, dispute of the amount) and continuing to auto-dun would be both a customer-relations and a compliance hazard.
3.6 Copy — plain, first-party, real numbers, no threats
Single copy module collections-chase-copy.ts (one source, shared by every touch), composed through the existing tenant SMS shell — never a hand-rolled fragment. Constraints (Lens 8 will be strictest here): plain English, no jargon, no legal-notice language, no threats, no late-fee math we didn't compute, every dollar figure from DelinquencyProfile.pastDueRent, recipient language via resolveRecipientLanguage(personId, org, 'collections') (ADR-0089 — add a collections surface). Three tone options for Gera (§3.7 Q3):
- A — Gentle reminder (recommended): "Hi {first}, this is {property}. Our records show a past-due balance of {$X} on your account. If you've already paid, thank you — please disregard. Otherwise you can pay through your resident portal: {link}. Questions? Just reply and we'll help."
- B — Warm + help-forward: leads with "we want to help you stay current" and offers to connect them with the PM about options, balance second.
- C — Brief + factual: "Hi {first} — a reminder that {$X} is past due on your {property} account. Pay via your portal: {link}. Reply here with any questions."
All three: first-party, no "debt collector" framing, no consequence/threat language, portal link primary CTA, reply invited (which triggers the pause+escalate in §3.5).
Amendment 2026-07-28 — per-message opt-out disclosure. The three sketches above are the tone menu as presented; the shipped copy additionally ends with Reply STOP to opt out. (es: Responda STOP para cancelar.), matching the renewal lane. Collections is debt messaging — the highest-scrutiny SMS category for carriers (A2P 10DLC) and regulators — so the opt-out path is stated in the message rather than left for the tenant to discover. This changes nothing about the gate stack: the inbound STOP-keyword handler and the ADR-0092 suppression check in §3.8 are unchanged and still evaluated before every send. The Spanish line names the literal keyword STOP because that is what classifyInboundKeyword exact-matches — "cancelar" is not a recognized keyword. collections-chase-copy.ts remains the single source of the shipped string.
Amendment 2026-07-28 — the English body is GSM-7; Spanish is UCS-2 on purpose. The tone constraints in this section are unchanged; this adds an encoding one. A single character outside the GSM-7 alphabet flips a whole SMS to UCS-2, cutting the per-segment budget from 153 characters to 67 — for a dun this length, 2 segments becomes 5. The grace line's em-dash (—, U+2014) was costing exactly that, so it is now a period, and a test pins the English body as GSM-7-encodable: a future flourish (—, curly quotes, …) fails CI rather than silently tripling per-message carrier cost across the cohort. Spanish stays UCS-2 and its copy is left untouched: realizó requires ó, which GSM-7 lacks (it has ò), so that body is 5 segments regardless of punctuation. Stripping the accent would trade correct Spanish for three segments — not on the table. Nor is its em-dash swapped for parity alone: that would be a pure copy change with no benefit, and it reads gracias — puede ignorar… directly before Puede pagar…, so a period turns one connected clause into two sentences opening on the same word. The segment win is taken where it is free and nowhere else.
3.7 Decisions (Gera signed off 2026-07-24)
- Gate shape → global durable arm + per-property (LOCKED).
COLLECTIONS_AUTONOMOUS_SENDINGenv /RENEWAL_ARMS.collectionsSendingdurable arm as the fleet-wide global, AND per-propertyautonomousCollectionsEnabled, both required, fail-closed, ships disarmed — the renewal precedent, the "genuinely dangerous, explicitly-requested" exception to hot rule 13. Built viacreateDomainGate({ envVar: 'COLLECTIONS_AUTONOMOUS_SENDING', propertyFlag: 'autonomousCollectionsEnabled', armField: 'collectionsSending' }). (The prospect-outreach per-property-only pattern exists because its app-side trigger can't see the worker's arm cache — that constraint does NOT apply here: collections is triggered entirely worker-side by the walker, so a global durable arm reads cleanly at both the walker and the send activity.) Requires addingcollectionsSending?: booleantoRenewalArmStateandautonomousCollectionsEnabled?: booleantoProperty. - Enrollment threshold →
balanceMonths >= 1(LOCKED). A full month behind. ConstantCOLLECTIONS_MIN_BALANCE_MONTHS = 1. - Copy tone → Option A, gentle reminder (LOCKED). The §3.6 A copy.
- Tenant-reply handling → pause + escalate to PM (LOCKED). §3.5.
- Channels → SMS-first for v1 (LOCKED). Email/voice are deliberate follow-ups (like vendor-chase's deferred SMS), not v1 scope. Default touches are all
channel: 'sms'.
3.8 The gate stack (each send, in order — reused verbatim from the proven blocks)
- Enablement (fail-closed). The gate from §3.7 Q1 — disarmed → dry-run, send nothing. A disarmed worker does zero per-property reads before NOOPing (cheap-first). [Superseded 2026-07-24: the enable arm was removed (see the first Addendum). Since 2026-07-28 the slot is occupied by the operator EMERGENCY BRAKE —
collectionsHalt, inverted polarity, absence = go — see the second Addendum.] - Premise + STOP re-read from the ledger + lease at fire time (§3.4). Row missing / not-past-due / exiting / unknown → stop or skip.
- ADR-0092 suppression on the tenant phone (
checkSuppression(phone, 'sms', 'outreach')—outreachcategory fails closed on lookup error). - 12h cross-path cooldown on the tenant conversation (prod-queue only, so compressed harness lanes aren't collapsed) via
isWithinOutboundCooldown. - At-most-once send claim (
claimOutreachSend→ dispatch →markOutreachSendSent, reused verbatim — channel/domain-agnostic, keyed on the workflow-minted idempotency key). Claimed after the deterministic checks, before Twilio.
Plus a quiet-hours hold (resolveOutreachQuietHoldActivity, held via condition, prod-queue only) so a touch is composed inside 8am–9pm property-local.
⚠️ Two later ADRs changed what this hold means, and the original sentence — "so no dun sends outside 8am–9pm property-local" — is now wrong twice over. ADR-0125 removed this lane's outbound path entirely: the touch writes a
pendingreview row and nothing here reaches a carrier, so this hold paces when a row appears on the review QUEUE, not when a resident is texted. ADR-0129 briefly moved the gate that DOES decide a send onto the resident's clock — superseded 2026-08-25, it is back on the property's clock (one standard, refusing rather than falling back whenProperty.timezoneis missing). This hold stays as composition pacing; it is not the TCPA gate.
3.9 Cadence + registry
Flip tenant.collections.delinquent from planned → configurable; add defaultTouches: DEFAULT_COLLECTIONS_TOUCHES (in outreach-cadence-schedule.ts, SMS touches, e.g. +0 [enrollment day], +3d, +7d — final ≤31d) + anchorLabel: 'the balance going past due'; remove the gapNote. The /settings Follow-ups card renders any configurable scenario generically (no card change — AUDIENCE_ORDER has tenant). Cadence read-at-cycle-start via resolveCollectionsChaseCadenceActivity({ propertyId }) → getEffectiveFollowupCadence('tenant.collections.delinquent', org), fail-soft to the registry default. Registry invariants satisfied: unique dot-path id, defaultTouches passing validateCadenceTouches, non-empty anchorLabel, src/-rooted source.
Entity classification (per ADR-0027)
No new entities. Reminder state is Temporal workflow state; sends record on the existing Conversation via recordOutreachConversation; delinquency comes from the existing DelinquencyProfile read-model. (This subsection is included to state affirmatively that the lane introduces no export interface requiring classification.)
4. Consequences
- Past-due tenants at armed, opted-in properties get a durable, gentle, ledger-honest dun cadence that self-stops the moment they pay — no cron, no invented ack, no fabricated numbers.
- Collections is the last
plannedfollow-up row; flipping itconfigurablecompletes the follow-ups program — every named outbound path is a real chase. - The lane ships disarmed; arming is a deliberate two-factor human decision (global arm + per-property opt-in, pending Q1), so no tenant is messaged until an operator explicitly turns it on.
- Adds a
collectionsoutbound-language surface (ADR-0089) and aCOLLECTIONS_MIN_BALANCE_MONTHSthreshold constant. - Follow-ups deliberately deferred: email/voice channels (SMS-first v1), a per-property threshold override, a payment-plan intake flow, and any owner-facing collections reporting — all out of scope, none blocking.
5. Alternatives considered
- Cron/sweeper enrollment — rejected (ADR-0006 + program rule): Temporal Schedule-gated walker owns the scan; timers live in workflows.
- Extending
renewalWorkflow— rejected: renewal is load-bearing, differently-keyed, and the collections lifecycle (ledger-stop, not offer-signing) shares no state. isPastDueas the enrollment premise — rejected: too twitchy; a full-month threshold is the non-spammy floor.Conversation.statusas the reply/stop signal — rejected: auto-reopens on any inbound (conversation-manager.ts:1548), the exact trap the team lane documented. The signal is an inbound message newer than enrollment.- A new persisted
CollectionsChase*entity for state — rejected for v1 (PARTIALLY REVERSED by the 2026-07-29 addendum: the CHASE RECEIPT persists terminal outcomes — the never-stops-dunning blocker proved cross-run memory cannot live in workflow state; within-run state stays in the workflow): workflow state + conversation rows suffice; a persisted entity would add an entity-classification surface and a second concern for no v1 benefit. - Per-property-only gate (no global arm) — offered as the Q1 alternative; recommended against for money messaging (no fleet-wide kill switch).
- A new global
*_AUTONOMOUS_SENDINGarm as the DEFAULT posture for a routine lane — this is normally forbidden by hot rule 13, and is proposed here ONLY under the money-messaging "genuinely dangerous, explicitly-requested" exception, which is Gera's explicit call (Q1).
Addendum — 2026-07-24 (same day): rip the arm, run a capture-only bake
Context. §3.7 Q1 shipped a two-factor autonomous-send arm
(COLLECTIONS_AUTONOMOUS_SENDING global + per-property autonomousCollectionsEnabled),
justified as the money-messaging exception to hot rule 13. On review of the merged
lane, Gera rejected the arm: "I don't like arms, it should just work — maybe have a
bake to capture if it worked." That is the hot-rule-13 preference (no arms; route
early-stage sends to the internal team, not customers) applied correctly. This
addendum records the revision.
Decision (supersedes Q1 + §3.8 Gate 0). The two-factor arm is removed
entirely — collections-chase-gate.ts, the COLLECTIONS_AUTONOMOUS_SENDING env,
the RenewalArmState.collectionsSending field, and Property.autonomousCollectionsEnabled
are all deleted (one source of truth; no dead flags). The lane now runs
unconditionally: the walker enrolls every eligible past-due tenant and the
workflow runs the full decision logic (ledger re-read, every stop, the exact dun
copy). The ONLY thing gated is the final step, by collectionsSendMode()
(src/lib/domain/leasing/collections/send-mode.ts):
'bake'(default) — CAPTURE the composed dun: write aCollectionsDunReviewrow into the/reviewqueue (+ a structured log) with the real past-due amount and the recipient, and text nothing. Zero tenant texts. This is the send-safe "route to the internal team, not customers" posture — the PM reviews the captures atpropflowai.co/reviewand confirms the lane duns the right people, the right amounts, and stops correctly. (Addendum 2026-08-05: as originally written this bullet ALSO fired a per-dun Slack alert,collections_bake_capture, to#alerts. That leg was removed by founder decision — would-sends go through /review ONLY; the historical captures were backfilled into the queue from the Temporal workflow histories.)'live'— actually text the tenant (dispatch + record on the thread), exactly as the original §3.8 path.
Graduation is a one-line, reviewed flip — change MODE in send-mode.ts from
'bake' to 'live' in a small PR when the captures look right. Not an env/DDB arm
that can silently disarm (hot rule 13's failure mode); a visible code change, the
same shape as the maintenance-gate rip. The follow-up cleanup that deletes the bake
branch lands once live is proven.
Amendment 2026-08-05 — the mode is resolved PER PROPERTY, not fleet-wide.
Everything above describes collectionsSendMode() as a single global switch, and
that stopped being the whole truth when the resolution became property-aware. The
flip is still ONE reviewed line (MODE), and it still governs every real property
— but a sandbox property (Property.isTest) is pinned to 'bake' permanently
and does not follow it. Callers that hold the property resolve through
collectionsSendModeForProperty(property) (the send activity does, as do the
collections list + tenant-detail loaders that drive the "Simulated outreach"
indicator); collectionsSendMode() remains the global value that function reads.
An unresolvable property also resolves 'bake' — fail toward not texting.
Same instrument and same reasoning as the 2026-07-28 isTest amendment above: a
bench property's fixture tenants are past-due by construction, so after the flip
a fleet-wide mode would have started texting the phone numbers attached to demo
fixtures. Founder framing (Gera, 2026-08-05): "the only one that should be
simulated could be the Yale Station 25 sandbox — for Camellia it should only be
real." The consequence worth stating plainly: after the flip, "capture-only" is
no longer a property of the lane, it is a property of the property — the
/review queue keeps filling from sandbox properties while real ones text, so a
row's presence in the queue stops meaning "nothing was sent to anybody."
Faithfulness. Every STOP (ledger, tenancy-exit, tenant-reply, suppression), the cooldown/claim gates, and the copy run identically in both modes — so the bake captures EXACTLY the cohort live would text. The only bake/live difference is a side effect, not a stop: on a tenant reply the cadence STOPS in both modes, but the PM escalation is LIVE-ONLY (in bake nothing was texted, so an inbound is not a "reply to a dun" and must not fire a false page — the escalation path is unit-tested independently). The send-claim also runs in bake, so a Temporal retry of a touch does not double-fire the capture alert.
The Temporal Schedule ships unpaused now (upsert-collections-walker-schedule.ts
defaults to unpaused) — capture-only is send-safe, so running the walk immediately is
the "it just works" posture. The schedule's paused state remains the only run control;
it does NOT control bake-vs-live (that is send-mode.ts).
Consequently the lane is NOT "dark behind an arm" — it actively runs and captures from day one, which is the observability Gera asked for; it just doesn't text real tenants until the deliberate flip.
Addendum — 2026-07-28: gate 0, the operator EMERGENCY BRAKE (collectionsHalt)
Context. The 2026-07-24 Addendum removed the enable arm; §5 (Alternatives) already recorded the residual worry with the per-property-only alternative — "no fleet-wide kill switch" for money messaging. This addendum adds that kill switch, without reversing the no-arms decision: a halt, not an arm.
Decision. RenewalArmState.collectionsHalt (durable CONFIG/RENEWAL_ARMS DDB
row, read via the arm-store's warm cache — isCollectionsHalted(), sync, ≤30s
staleness) is gate 0 of the §3.8 stack, checked before any read in
sendCollectionsChase. Pulled (true), every touch — live send AND bake capture —
returns a logged, resumable skip (skipped: 'halted'); the workflow keeps the
cadence alive, so releasing the brake resumes the remaining touches. Operator lever:
npx tsx scripts/set-renewal-arms.ts --collections-halt on|off --apply. Inventoried
on the Arms tab as collections.halt (polarity inverted_off).
Polarity is the whole point. Absence = GO: an unwritten row/field can never
leave the lane dark (hot rule 13), and a deploy can neither pull nor release the
brake (no env fallback). Enablement is untouched — going live remains the reviewed
collectionsSendMode() one-line flip. The brake is the reverse lever: a no-deploy
STOP for a lane that is already running.
Operational caveats (documented on gate 0 itself): touches that fire while
halted are dropped for that tenant, not re-queued; the reply→PM escalation is
deferred with them; and the brake does not stop ENROLLMENT — pulling it for more
than ~a day should be paired with pausing the collections-cohort-walker-daily
Schedule. For cancelling in-flight cadences outright, see
scripts/temporal/cancel-collections-chases.ts (break-glass, dry-run default).
Addendum — 2026-07-29: the durable CHASE RECEIPT — §2's "no new persisted entity" is amended
Context. §2 decided "No new persisted entity — reminder state lives in durable
Temporal workflow state", and §5 rejected "a new persisted CollectionsChase* entity
for state". That held for state within a chase — but this lane's unit of memory must
span runs, and workflow state evaporates when the workflow closes. The daily walker
had no chase-history rung, and the client starts collections-chase-<tenantOccupancyId>
with USE_EXISTING + ALLOW_DUPLICATE: RUNNING → no-op, CLOSED → a fresh run with a
fresh anchor. Three failure modes, one root cause:
- Silent tenant, dunned forever — cadence exhausts (~7 days) →
completed→ next 9am walk re-enrolls → ~11-12 duns/month indefinitely. - Replied tenant, re-dunned next day — the §3.5 reply-stop compares the inbound to the current run's anchor; re-enrollment mints an anchor NEWER than the reply, so touch 1 fires ~1 day after the human handoff, re-paging the PM each cycle. §3.5's promise held only within one run.
- STOP'd tenant churns forever — suppression was a per-touch SKIP, so the run walked
all touches empty, closed
completed, re-enrolled weekly. Unfixable at the reply gate: the STOP keyword is intercepted in the Twilio webhook BEFORE any conversation append, so it can never appear as arole: 'tenant'message.
Notably, the prospect lane — this ADR's own cited template — never relied on workflow
state alone: it persists a terminal outcome (recordOutreachOutcomeActivity) AND an
outreachCadenceAnchorIso short-circuit in outreach-trigger.ts. Collections copied
the workflow shape and skipped the durable stamp. That in-repo precedent is why this
amendment persists terminal outcomes rather than inventing a new pattern.
Decision. A per-tenant chase receipt —
COLLECTIONS_CHASE_RECEIPT#<tenantOccupancyId> / RECEIPT
(src/lib/domain/leasing/collections/chase-receipt.ts, row idioms mirroring
outreach-send-claim.ts: plain put, native TTL). Written activity-side from the
sendCollectionsChase wrapper — deliberately NOT a workflow-side "record outcome"
activity, which would need a patched()-gated workflow change with real histories in
flight; activity-side achieves the same durability at zero determinism risk. (Migrating
to the prospect-style at-exit activity is the right long-term shape whenever the next
patched() workflow change happens anyway.) Read in the walker's classifyTenant as a
new ladder rung → counted skip recentlyChased, surfaced on the walk headline.
Per-outcome policy (one mechanism, three policies — a blanket cooldown alone would let Mode 2 recur at lapse and merely slow Mode 3; a durable reply-stop alone would fix only Mode 2 and, without an episode-clearing rule, permanently mute stale repliers):
| Chase end | Receipt | Walker behavior |
|---|---|---|
paid / observed cure |
deleted | re-enrollment stays INSTANT for pays-then-re-delinquents (the walker clears on a cured ledger — it is the only observer once a chase closes) |
completed (exhausted, silent) |
completed, TTL = cooldown |
blocked while the receipt lives |
tenant_replied |
tenant_replied, TTL = cooldown |
blocked — a human owns the thread |
| suppression (now a cadence STOP, not a per-touch skip) | suppressed, TTL = cooldown |
blocked — even expired, ADR-0092 still prevents any text; the receipt only stops the churn |
exiting / no-tenant |
none | the ladder already excludes these |
The owner-tunable line: COLLECTIONS_REDUN_COOLDOWN_DAYS = 30 (chase-receipt.ts) —
≈ one reminder cycle per newly-missed rent month. The documented alternative (strict
never-re-dun-until-cure) is a one-line change described on the constant. Failure
posture: receipt READS fail open (a DDB blip never freezes enrollment; the send gates
still protect every touch); WRITES never throw (a receipt failure never blocks a stop).
Receipts are mode-blind (bake and live write identically) so the bake keeps proving
the live cohort. Bypassing the rung requires the explicit bypassChaseReceipt option —
deliberately NOT coupled to forcePropertyId/forceTenantId, which only scope a walk
(a forced re-enrollment mints a fresh anchor that hides a tenant's reply — Mode 2 on
demand). Known residual: a zero-touch (org-disabled)
cadence makes no activity call, writes no receipt, and churns a harmless empty workflow
daily — out of scope here.
One sentence on §2's original rationale: "keeps the lane free of the entity-classification
drift guard" is now void as a load-bearing argument — the receipt interface lives in
src/lib/domain/leasing/collections/ beside outreach-send-claim.ts / renewal-mint-claim.ts,
the guard's documented blind spot for claim/receipt row stores, so the guard was never the
real cost of persisting an outcome.
(House-keeping: the pilot ADR is now 0117; this remains the lane ADR.)
Addendum — 2026-07-29: the ADR-0117 Phase-1 hard exclusions — In Collections + Certified Funds Only
Context. ADR-0117 Phase 1 lists hard exclusions the pilot must never dun through; the two account-flag ones were unimplemented in the shipped lane, and the 2026-07-29 audit ranked In-Collections as the single highest-risk excluded population. A tenant whose account the PM has placed with a collections agency gets a first-party dun running in parallel with the agency arrangement (a real conflict that muddies dispute/validation posture), and AppFolio disables the resident portal for exactly these accounts (In Collections and Certified Funds Only both do — KB-verified in ADR-0117 §AppFolio mechanics), so the dun's primary CTA ("pay through your resident portal") points them at a dead link. The data was already synced and ignored.
Freshness finding (decides the design). TenantPmsSnapshot.occupancy.inCollections
exists but is written ONLY by the manual per-tenant "Sync from AppFolio" button
(syncTenantFromPms — its other caller is the PMS-sync canary, which touches one
bench entity; the "daily auto-sync cron #75" its comments mention was never
built). The snapshot can be arbitrarily stale or absent, so gating a money send
on it is unsafe — a tenant placed into collections last week would still be
dunned off a June snapshot. The lane already reads AppFolio LIVE per tenant at
walk and fire time (getDelinquencyProfile → charge_detail), so the flags are
read live too, from the AppFolio delinquency report (which carries both
InCollections and CertifiedFundsOnly), at the same freshness posture and
lower cost (one report call covers a whole property).
Decision. Both flags are excluded at BOTH layers, PMS-agnostically:
- Plumbing. New optional
PMSClient.listCollectionsAccountStatuses(externalPropertyId)(capabilitycollectionsStatus) returning PMS-neutral{ externalOccupancyId, inCollections, certifiedFundsOnly }rows; AppFolio implements it off thedelinquencyreport (integrations/appfolio/collections-status.tsparses the "Yes"/"No" columns at the boundary). Domain read:src/lib/domain/leasing/collections/account-status.tswith a three-wayok / unsupported / errorcontract —unsupported(PMS has no flag feed) is not an error; the flags cannot exist there. - Enrollment (walker). Two new ladder rungs AFTER the ledger rungs (so the
counts mean "past-due tenants deliberately held out") and BEFORE
recentlyChased(bypassChaseReceiptoverrides cross-run memory, never a compliance exclusion): counted skipsinCollections/certifiedFundsOnly, surfaced on the walk headline. In-collections outranks certified-funds when both are set. One flags read per property per walk. A FAILED read walks fail-open — counted aspropertiesAccountStatusUnavailableon the result + headline, never silent — because the send activity fail-closes (same layering as the receipt rung). - Fire time (send activity, gate 1c). Re-read live per touch, right after
the gate-1b ledger re-read and for the same reason: a PM can place an account
into collections MID-CADENCE. Both flags STOP the cadence
(
in_collections/certified_funds_only) — the same shape as theexitingstop (another actor owns the tenant now). No chase receipt is written for these stops, so a tenant whose flag clears while still delinquent re-enrolls on the next daily walk rather than serving out a 30-day receipt. An UNREADABLE feed is a fail-closed per-touch SKIP (account-status-unavailable), mirroringunknown-ledger; a PMS without the capability proceeds.
Why exclusion (not portal-link suppression) for Certified Funds. ADR-0117
Phase 1 scopes both flags as hard exclusions, and once the delinquency-report
read exists for In-Collections, the Certified-Funds plumbing is the same read —
the "suppress the portal link but still send" alternative was the fallback for
plumbing cost that never materialized. Softening certified-funds tenants to a
no-portal dun remains open to the pilot owner as a copy-level follow-up
(buildCollectionsSmsBody already has the no-portal payLine fork).
Addendum — 2026-07-29: the LLM legal auditor gates the composed copy (compliance-harness wiring, part 3)
Context. The ADR-0117 compliance harness has two halves. The deterministic
half (assessCollectionsLegality) became a runtime gate earlier the same day
(#4873): facts in, arithmetic verdict out — jurisdiction support, contact-day
gating, the route-to-human stop. What arithmetic cannot see is IMPLICATION
("we'd hate for this to go any further" asserts nothing checkable and still
implies an imminent eviction), and implication lives in the composed English.
The LLM auditor (reviewCollectionsMessage, Haiku, temp 0, forced tool call,
fail-closed on any error) existed as a tested library + 33-case adversarial
eval, but the lane never called it.
Decision. sendCollectionsChase now audits the COMPOSED dun body on every
touch, as gate 4b:
- Placement. The compose block (language + portal link +
buildCollectionsSmsBody— pure/read-only, safe to hoist) moved ABOVEclaimOutreachSend, and the audit sits between compose and claim. The constraint that decides this: the activity'sstartToCloseTimeoutis 60s while the Anthropic client's safety timeout is 150s, so a hung call blows the ACTIVITY timeout — before the claim, the Temporal retry re-runs the gates against a claim-free row and the touch survives; after the claim it would drop assend-claim-pending, a silent loss on a money message.legal-review.tsadditionally caps its own request at 20s so the common hang fails CLOSED inside one attempt. The whole legality pipeline (deterministic gate 1d + LLM gate 4b) is claim-free. - Input. The audit reads the SAME
CollectionsAssessmentgate 1d computed — the prohibitions in force for this tenant today, fee ceilings scaled to this balance — so the two halves grade one consistent legal state. - Verdict handling. Non-compliant OR the fail-closed fallback → per-touch
SKIP (counted
legal-blockedin the workflow snapshot, no workflow-file change → replay-safe) + acollections_legal_blockedalert carrying the blocked draft, the verdict, and anysuggestedRewrite— which is FOR HUMAN REVIEW and never auto-sent (dispatching copy no human reviewed is the failure class the gate exists to prevent). Never a workflow failure. A skip, not a stop: the verdict is per-composition and a transient classifier error must not end a lawful cadence. - Both modes. The audit runs in bake AND live. A bake capture of copy the auditor would block is a false "the lane would text this" — and the bake period's shadow verdicts on real (body, assessment) pairs are the evidence for the go-live flip, at zero tenant risk.
- Per-touch, not per-copy-version. A verdict cache keyed on a hash of the template shape was considered and rejected: the verdict is a function of (rendered body, assessment), and both vary per tenant and per day — the body interpolates the dollar amount and name; the assessment's lawful-fee ceiling scales with the balance. A shape-hash would need a proof of verdict-invariance across every interpolation, which dissolves the moment copy becomes dynamic (the future the auditor exists for), and it would collapse the bake's shadow-verdict sample to one row per template. Honest cost of the per-touch shape: ~1-3s p50 and ~$0.0003/touch on Haiku — at ~3 touches/mo × tens of tenants, well under $0.05/month.
- Circuit breaker: stateless, on purpose. A permanently-blocked template
does not skip silently forever: every blocked touch fires the alert
(deduped per tenant per 24h; ACTIONABLE — email + Slack — once the lane is
live, Slack-only in bake, mirroring
collections_missing_property_number), so a fleet-wide template block pages once per affected tenant per day; and the final-touchcompletedchase receipt bounds a blocked cadence to one run per receipt window instead of weekly re-enrollment churn. A durable consecutive-block counter would add a new state row + reset semantics to buy loudness the alert already provides; it can be added later without unwinding anything if the alert volume proves wrong.