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