Six parallel audits  ·  Opus 5 + Fable 5
Verified against main  ·  2026-07-28

Is the collections system
ready to text a resident?

Architecture · ledger integrity · consent · audit trail
the review gate · and an adversarial red team.
VERDICT  ·  NOT YET
docs.propflowai.coCOLLECTIONS READINESS
02 / VERDICT
The verdict

Not ready — but nothing has reached a resident.

The lane composes every message, posts it to Slack, and texts nobody. That is the only thing protecting us right now.

Residents contacted
0
bake mode — capture only
Distance to live
1line
a constant in send-mode.ts
Critical findings
6
behind that line
Controls claimed, unbuilt
4
asserted in ADR-0117
The safety margin is real, and it is one line of code wide. Graduating from capture to live is a single reviewed flip — behind it sit six defects that would hurt a resident or lose a case.
Six-agent audit · 2026-07-2802
03 / FINDINGS
What's behind the line

Six critical findings

01 · The money

Wrong balance by construction

The dollar amount we text is a reconstruction, not the ledger number.
02 · Cadence

The cadence never ends

~3 touches every 8 days, forever — not "3 per month."
03 · Scope

No population guardrails

Every property, every org. None of the pilot's scoping exists.
04 · Eligibility

Market rent regrades who's collectible

A wrong denominator moves the “full month behind” line — it changes who gets dunned.
05 · TCPA

Quiet hours already wrong

Live today — independent of everything else here.
06 · Identity

Co-tenant double-dun

Both texted the full amount, on unsynchronized cadences.
Ranked by resident harm, not engineering effort03
04 / FINDING 01
Finding 01 · the money

We don't text the ledger number.
We text a reconstruction of it.

unpaid total
÷ monthly rent
balanceMonths
× monthly rent
quoted $
The true figure
$1,637.49
What the resident actually owes.
What we text
$1,632
At $1,600 rent. Ordinary inputs — not an edge case.
The copy module's own comment says “Exact past-due rent… Never rounded up.” That comment is false — and the type's doc-comment one file over admits the field is “Approximate”.
delinquency/index.ts:62 · re-verified against main04
05 / FINDING 01
Finding 01 · why it goes first

It trips your own kill criterion, on day one.

ADR-0117 lists “any validated wrong-balance message” as a condition to pause the pilot. Under Colorado law the balance is the factual predicate for everything else the message says — a wrong number is not a rounding bug, it is a false dunning claim.

Also feeding the number

Credits never subtract

A $500 concession or correcting credit leaves the dun $500 high — negative rows clamp to zero.
And a silent maximiser

No field-drift guard

If AppFolio renames PaidAmount, every charge reads fully unpaid — up to a 12× over-quote.
Second surface

Two numbers disagree

Ask Clara "what do I owe?" and a different, fee-inclusive, possibly hours-old figure comes back.
feature-extractor.ts · balance-read.ts · ADR-0117 kill criteria05
06 / FINDING 02
Finding 02 · the follow-up strategy

The cadence doesn't stop. It restarts.

enrol
+1h
+3d
+7d
workflow completes
daily walk re-enrols
ADR promises
3/mo
"max 3 touches per month"
Actual
~11/mo
3 every ~8 days, indefinitely
Cross-lane cap
none
no 7-in-7 counter exists
Only throttle
12h
and it sees one conversation row
A chronically delinquent resident is dunned forever. This is the harassment scenario — and the fair-housing cadence-disparity generator.
outreach-cadence-schedule.ts · collections-chase-client.ts:5506
07 / STRATEGY
Finding 02 · the change you asked about

The follow-up model needs a per-person ceiling,
not a per-workflow one.

Every lane owns its own cadence and nothing counts across them. Collections, renewals and maintenance follow-ups can all land on the same resident in one week without any of them knowing.

What exists today

Per-workflow timers

Plus a 12-hour recency check scoped to a single conversation row. Email sends never touch that ledger at all, and the send-claim row expires after 7 days — so it can't be retrofitted into a counter.
What's needed

One contact ledger, per person

Durable, counted over a rolling window, written by every lane and read before any outbound. This is the strategy change — not a collections-specific patch.
autonomous-outreach-cooldown.ts · outreach-send-claim.ts07
08 / FINDING 05
Finding 05 · live in production today

Quiet hours are already wrong, right now.

The federal rule
TCPA: 8am–9pm in the recipient's local time.
What we actually do
Use the property's timezone.
That field
Property.timezone has zero writers anywhere in the repo.
So it falls back to
a hardcoded America/Chicago.
Net effect
A Colorado property runs on Central time — a one-hour skew on a federal rule.
It's also only checked in the workflow loop, never at the send chokepoint — so any direct activity call bypasses it entirely. This is true today and has nothing to do with the new work.
quiet-hours.ts:63-94 · re-verified: no writer exists08
09 / CANDOUR
The uncomfortable finding

The compliance harness shipped today gates nothing.

Legal rules encoded
31
cited to primary sources
Adversarial eval cases
31
passing, live
Unit tests
79
green
Production callers
0
imported only by its own tests

The live send path composes copy and dispatches with no compliance call in between. Latent rather than live-dangerous — today's template is fixed and mild — but the moment adaptive tone or any LLM-drafted copy lands, nothing enforces Colorado law.

A session spent building a harness and correcting docs that claimed behaviour the code lacked, which ends with the harness itself decorative, is the same failure one layer up.
compliance/collections/* · zero importers outside __tests__09
10 / PATTERN
The systematic pattern

The ADR describes a system we haven't built.

01 · CADENCE

“≤7 contact attempts per 7 days”

No counter exists anywhere. A 12-hour recency check is not a rolling window. Grep returns prose only.
02 · CADENCE

“Max 3 touches per month”

Directly contradicted by the re-enrollment loop on slide 06.
03 · SCOPE

“Portal-payer population only”

No tender-type determination exists. paymentMethod has no writer, and the delinquency report is never called from the collections path.
04 · AUDIT

CollectionsCase

Zero hits in src/. The state machine, per-touch audit trail and ledger snapshot are all unbuilt — the evidence spine simply isn't there.
Not sloppiness — an ADR written as a design, then read later as a status report.
docs/adr/0117-collections-ai-pilot.md10
11 / EVIDENCE
If this ends up in court

We couldn't reconstruct what we said,
or prove what we didn't.

Provenance

No ledger snapshot per message

And it can't be rebuilt afterward — the hourly snapshot is a different, fee-inclusive number the ADR itself records as disagreeing with the source.
Immutability

Messages mutate in place

Last-write-wins with two live in-place mutators. No append-only journal, no stream archive.
Completeness

“We sent nothing else” expires at 90 days

The daily cohort-scan record is the only proof of what was evaluated. Well inside the 4-year TCPA window.
Access

No financial gate

Any org member scoped to the property can read every dun. Conversation routes never call the permission checks.
conversation.ts · automation-run.ts:49 · permissions-source.ts11
12 / HEALTHY
Not everything is broken

Four things are genuinely right.

Fail-closed

Unknown never means “current”

The delinquency read returns unknown — never a falsy "not delinquent" — on every missing-data path. A broken AppFolio means no send, not a wrong send.
Opt-out

Global, cross-channel, fail-closed

A STOP on a maintenance thread does stop a collections dun. Voice and SMS share the partition; it fans out to email claims.
Retention

Nothing evidentiary expires

No TTL on suppression, consent or conversation rows — the four-year record is intact. Fragile though: table-wide TTL is now live with no drift guard.
PII boundary

Clean through Temporal

Ids only across the workflow boundary. Names, phones and dollar amounts all resolve inside the activity. No leak found.
delinquency/index.ts · suppression.ts · collections-chase.ts12
13 / ACTION
If you do one thing

Fix the number.

Carry the true unpaid total instead of reconstructing it from a rounded ratio. The number already exists — the extractor computes it one module upstream and throws it away. Then delete the comment that claims the amount is never rounded.

Why this and not the legal wiring: the harness protects against things the agent might say. The balance is what it says. A perfectly compliant sentence built on a wrong number is still a false dunning claim — and it's the one defect that trips your kill criterion on day one.
Estimated: small, contained, testable13
14 / PLAN
Before the bake → live flip

Six things, in this order

01 · MONEY

Fix the balance

Carry the total the extractor already computes · correct the false comment.
02 · CADENCE

Inter-cadence cool-off + per-person attempt ledger

Shared across lanes. The 7-day claim row can't be retrofitted into one.
03 · SCOPE

Implement the population the ADR already promises

Portal payers only · exclude flagged, disputed and opted-out units · settle the rent denominator.
04 · TCPA

Recipient-resolved quiet hours, checked at the chokepoint

Not only in the workflow loop, which a direct call bypasses.
05 · LEGAL

Wire the compliance library in

At the send activity, after the ledger re-read.
06 · EVIDENCE

Build the case record with a pinned ledger snapshot

Nothing else makes the rest provable after the fact.
Order matters more than speed14
15 / PLAN
Do these whatever the timeline

Two cheap guards, independent of the flip

Guard A

A drift guard on retention

Assert suppression, consent and conversation rows never carry an expiry. Table-wide TTL went live on prod on 2026-07-26 and is irreversible — and the TTL audit doc records this exact bug shipping four times already.
Guard B

A kill-switch runbook

Today “stop everything now” means a code deploy or Temporal CLI surgery. Pausing the schedule only stops new enrollments — in-flight workflows keep running for up to 45 days.
ddb-ttl-audit.md · send-mode.ts · collections-chase-workflow.ts15
16 / DESIGN
The human-in-the-loop design

Approval approves a pair — never a send.

A message approved at 10am and sent at 4pm asserts a balance that was true at 10am. If the tenant paid at noon, we've sent a false claim with a human's name on it — worse evidentially than an autonomous mistake, because we documented that someone reviewed it.

compose
review row
human approves (facts, text)
re-validate at dispatch
send
If the balance moved, the approval is void and a fresh review is raised. Slack notifies and hosts the discussion; approval lands in PropFlow — same shape as a PR.
Pattern precedent: mass-comms-workflow.ts:504 canary hold16
17 / CANDOUR
Corrections

Three claims I published were wrong —
one of them is on slide 04.

I wrote
Reg F's caps “cost us nothing to honour.”
Actually
There is no counter. We don't honour them.
I wrote
Quiet hours enforced “8am–9pm recipient-local.”
Actually
Property-local, Chicago default, no writer for the field.
I wrote (slide 04)
Market rent is quoted to the resident as the amount owed.
Actually
The same rent divides and then multiplies, so it cancels out of the dollars. The damage lands on eligibility instead — see the corrected slide 04.
The first two I inherited from ADR-0117. The third I derived myself and never traced the call path — the same pattern this deck criticises one layer up. Finding 04 is re-stated, not withdrawn: the defect is real, my mechanism was not.
Re-traced against main @ 2051e33f9 · delinquency/index.ts:88, :145, :14917
18 / CLOSE

The safety margin is real —
and it is one line wide.

Nothing has reached a resident. Everything here is fixable before anything does.
The order matters more than the speed.
docs.propflowai.co/a/collections-readiness-deckPROPFLOW · COMPLIANCE

All slides

PropFlow Docs