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.

⚠️ SUPERSEDED IN PART BY ADR-0125 (2026-08-06): the 'bake' | 'live' send mode is GONE. Everywhere below that describes a mode, a MODE constant, 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.


1. Context

What exists today

The forces


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 itselfgetDelinquencyProfile 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.

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:

  1. occ.status === 'former'archived (already moved out)
  2. lease.evictionPending === trueeviction (legal track owns it — never dun someone in eviction)
  3. lease.lastObservedNoticeAt set → ntv (on notice — turnover owns it)
  4. lease.leaseMoveOutDate set / lease.archivedAt set → movingOut
  5. profile nullunknownLedger (fail-closed — never enroll on an unreadable ledger)
  6. !profile.isPastDuecurrent
  7. below the collectibility threshold (§3.3) → belowThreshold
  8. else → eligible → start collectionsChaseWorkflow (idempotent via USE_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):

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:

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):

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)

  1. Gate shape → global durable arm + per-property (LOCKED). COLLECTIONS_AUTONOMOUS_SENDING env / RENEWAL_ARMS.collectionsSending durable arm as the fleet-wide global, AND per-property autonomousCollectionsEnabled, both required, fail-closed, ships disarmed — the renewal precedent, the "genuinely dangerous, explicitly-requested" exception to hot rule 13. Built via createDomainGate({ 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 adding collectionsSending?: boolean to RenewalArmState and autonomousCollectionsEnabled?: boolean to Property.
  2. Enrollment threshold → balanceMonths >= 1 (LOCKED). A full month behind. Constant COLLECTIONS_MIN_BALANCE_MONTHS = 1.
  3. Copy tone → Option A, gentle reminder (LOCKED). The §3.6 A copy.
  4. Tenant-reply handling → pause + escalate to PM (LOCKED). §3.5.
  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)

  1. 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.]
  2. Premise + STOP re-read from the ledger + lease at fire time (§3.4). Row missing / not-past-due / exiting / unknown → stop or skip.
  3. ADR-0092 suppression on the tenant phone (checkSuppression(phone, 'sms', 'outreach')outreach category fails closed on lookup error).
  4. 12h cross-path cooldown on the tenant conversation (prod-queue only, so compressed harness lanes aren't collapsed) via isWithinOutboundCooldown.
  5. 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 pending review 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 when Property.timezone is 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

5. Alternatives considered


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 entirelycollections-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):

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:

  1. Silent tenant, dunned forever — cadence exhausts (~7 days) → completed → next 9am walk re-enrolls → ~11-12 duns/month indefinitely.
  2. 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.
  3. 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 a role: '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 receiptCOLLECTIONS_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 (getDelinquencyProfilecharge_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:

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).

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: