Live tracker

Collections — Phase Tracker

The build plan from Collections — Three Phases, as a checklist. Tick items as they land; the boxes save for everyone. Evidence column carries the PR or the Slack post so "done" always has something behind it.

Phase 10 / 0
Phase 20 / 0
Phase 30 / 0

Team alignment — 27 Aug (founders standup)

Source: the founders standup call (Gera, Federico, Sean), transcript in Slack #transcripts. The headline call: collections is explicitly DEPRIORITIZED behind scaling to ~5,000 units and hardening renewals + human-in-the-loop. Fede: "we're gonna start hitting sales and scaling to properties, I think that should be number one... if one of the customers is like hey we really want collections, I think we could consider going deeper." Sean agreed, and said he'd rather the time collections would take go into making sure renewals is solid, because right now "if it hits a roadblock, it just stops." Read every row below with that framing — this page is not the current top priority, on purpose.

What "Agentic" means here — settled, after real disagreement. The team explicitly rejected "send reminders" as the differentiator: "every PMS has that" (Fede), and AppFolio already sends its own late reminders today. What both Fede and Gera landed on as the actual value-add: (1) auto-drafting the Colorado 10-day demand/pay notice for Joanna to review and sign, pre-filled from the email-derived collection activity — Joanna herself, asked in person, called this "insane... that would save me so much time" — and (2) collecting the tenant's stated REASON for being late (job loss, etc.) and handing that to the property team as a fact they'd otherwise have to chase manually. "Dude, that's Agentic" (Fede); "I think that's a good agent, I like that" (Gera). This is exactly what p3a/p3b/p3g already build toward — the meeting confirms the direction, doesn't change it. Keep the existing 30/60/90 tracking dashboard as-is; both agreed it's good and should stay.

⚠️ Reminder CADENCE is explicitly UNRESOLVED — do not read it as decided. Sean proposed a full pre+post-due escalation sequence (day‑25 email, day‑28 SMS, due‑date SMS, day‑2 SMS+email, day‑4 SMS, day‑before‑late‑fee SMS+email). Fede pushed back hard — called it not-Agentic ("Web 2.0" thinking) and worried it annoys residents who already pay on time or are on autopay — and countered with something much leaner: audit once around day 3 and send one reminder, then hand off to the property team. Gera leaned toward the lean version but noted "a lot of people pay pretty late." Nobody picked a side; Fede: "I'll talk to Sean separately... maybe I'm being stubborn." Deferred to a Fede/Sean 1:1, not this meeting. Ground truth for Camellia specifically: no late fee today, and the real informal practice is Joanna checking the delinquent list around the 2nd–3rd of the month — which is closer to Fede's lean proposal than Sean's multi-touch one. This bears directly on p3i (the preventative pre-due reminder added below) — that row stays unscoped until this cadence question resolves.

New idea, not yet built — demand letters "bake" in Slack before printing. Gera's proposal: whenever a demand notice is ready, post it to the Camellia Slack channel (via Agent Smith) — "hey, we have a demand ready, let's go ahead and print it" — so the team can confirm before it goes out, rather than it being a silent artifact only visible in the product. Same shape as the approve-link Slack carrier shipped earlier today (PR #6423) — likely reuses the same notify() plumbing. Gera said he'd "set up the baking for now." Tracked as p3j below.

Also raised, not scoped: a nightly cron to keep Colorado late-fee/legal limits current, so the system can never draft a non-compliant notice (Gera) — this would be one path toward closing the "five of eight compliance facts are hardcoded false" item already in Standing risks below, not a separate new gate. And a longer-term idea from Fede — the AI giving a compliance "second opinion" on an owner's policy choice (e.g. late-fee amount) since "Colorado law changes all the time" — is a sales narrative point for now, not a scoped build.

Payment plans — reconfirmed Phase 2, and clarified: no AppFolio dependency needed. Fede: "a payment plan is just a promise to pay, with amounts and dates... you could put it in AppFolio, or you could handle it in PropFlow." If a tenant breaks the promised plan, PropFlow notifies the property team — it becomes their problem again, not an automated re-chase. Grace period and lawyer-escalation policy is owner/director-set (Kenya for Camellia), not an AppFolio setting. Gera added a payment-plan question to Camellia's Slack meeting-notes canvas during the call, so there's concrete input from Kenya's team by the time Phase 2 starts even though no work is happening on it yet.

Explicitly rejected, both restated from 25 Aug and newly reaffirmed: no lawyer-referral service for tenants (Gera: "that sounds like a headache... probably not ideal"); no UI-first approach — "there is no need for UI... Clara just does the letter" (Gera/Fede) — the only UI surface anyone wants is a signature step, which is p3a's territory, not a new form.

Open action item, not done: Gera wants Joanna's actual previously-printed demand-letter PDFs (likely sitting in her own email, self-forwarded) so a generated notice can be pre-filled to match what she already sends, rather than inventing a new format. Not yet requested as of this meeting.

⚠️ Do not cite as legal fact: Gera mentioned Colorado's late-fee cap as "like 5% or 50 bucks" on the call, from memory — that is his recollection, not verified research on this page, and should not be treated as a confirmed limit until checked.

Where this stands — 25 Aug

✅ THE FIRST RESIDENT HAS BEEN TEXTED — 2026-08-25, 19:57 MDT. Gera approved it on /review and it left 497 ms later. Twilio's own record says status: delivered, error_code: null, from Camellia's toll-free line, 2 segments — so the carrier carried rent copy without a complaint, which is what the registration question in p2h was really asking. Balance quoted: $2,700, re-read live from AppFolio at compose time. Nothing about the phone number's paperwork changed to make this possible.

Camellia's queue now reads { pending: 77, sent: 1 }. One send, ever. Everything still stops at a person clicking approve.

⚠️ None of the other 77 can be sent. They are 16–28 days old (median 21), and a captured reminder may only be approved within 48 hours of being written — after that the system can no longer vouch that the balance it quotes is still true, so approval refuses with stale_capture rather than sending. Nothing else on the review queue is something a click could turn into a text. That is exactly how the first send worked: a FRESH touch was run for the chosen resident, the cadence re-read the ledger, re-applied every stop (paid, moving out, payment plan, in collections, opted out), and composed against today's balance — which had doubled, $1,350 → $2,700, since his stale row was written on 2026-07-28. Approving the old one would have quoted a real person half of what he owes. That is the two-day window earning its keep on the first real case.

✅ Quiet hours proven too, 23:21 MDT the same night: a fresh capture approved during quiet hours refused quiet_hours, sent nothing, and stayed pending. So the gate is demonstrated in BOTH directions on the same property, four hours apart. Still to exercise: the first real inbound reply — hardship, dispute, or STOP — which is built and tested but has never met a live resident message on this lane. PR #6291 hides the 77 expired captures from the queue once it merges.

Phase 1 is closed. Phase 2 is built — the reply gate, the language gate, escalation, the stage holds, and as of today quiet hours run on the property's clock, refusing to send rather than guessing a building's time zone.

Nothing is left to build, and the carrier paperwork is NOT a blocker — corrected 2026-08-25 after reading Twilio's own guidance. Camellia's number is toll-free and its verification is approved, for a use case that describes maintenance traffic and never mentions rent. The resident-facing half is fixed and live: the privacy policy and consent page now say we text about rent and past-due balances (#6260).

The correction matters, because it points the other way. Twilio has no edit-in-place for an approved toll-free verification: changing the use case or the opt-in flow means delete-and-resubmit, the number drops to Pending, and a Pending toll-free number cannot send at all — maintenance and work orders included — until it is re-approved, at the back of the queue. Twilio's own wording: “No deletion and resubmission is required if you're only making minor changes to your use of the toll-free number for messaging, such as the message content.” So sending a rent reminder touches the registration not at all, while “fixing the paperwork first” is the move that takes the line down. Outbound renewal traffic already rides this number under the same summary.

The registration item that does need delete-and-resubmit is separate and deliberate: the verification declares opt_in_type: WEB_FORM with a web screenshot, while the real provenance is the number the resident gave the property through AppFolio. Worth fixing on a planned window, not as a side effect of a first send.

Counsel: deliberately skipped for the first-party question (Gera, 2026-08-25). Our own research is that the federal and Colorado collections statutes do not bind a landlord chasing its own current residents' rent. The risk being accepted is that nobody outside the company has confirmed it, and the one fact engineering cannot see is whether any balance was acquired already in default. Payment plans stay carved out — the research names that as the capability that genuinely needs a lawyer first, and it is a Phase 3 row.

Landed 19–21 Aug — Phase 1, now closed

Collections mail is now read, swept, tagged and reaching the extractor; the eviction firm's court-filing receipts have stopped being deleted; a drift guard now fails the build if a customer's identifiers reach shared source; and /collections shows a derived lifecycle Stage instead of a permanently-blank operator column. Still open — ONE row, and not the two this sentence used to name. Both of those closed: the signals → stage write path landed, and Fede's demand builder shipped (#5930) and has been worked over repeatedly since (#6211 and after). The row still open is “Ship the benchmarked extractor prompt”, and it is open for a reason worth reading rather than clearing: the measured win is unverifiable forever — zero goldens carry category, and the corpus that produced 0.667 is gone. That row needed a decision, not a tick, and it has one: Gera answered done on 2026-08-25 — the work ran, and its deliverable was the measurement rather than a shipped prompt. Phase 1 carries no open rows. The bar above is computed from the rows — trust it over any count written in prose.

How to use this

Phase 1 · Track

PHASE 1

Make the pipeline visible

no resident is contacted

Kenya stops emailing the site team to ask what is happening, and Joanna stops filling a legal form with a pen. Nothing here sends a message, so nothing here needs a lawyer or a carrier. Assistance leads — it is the modal outcome of a demand and 3× the mail volume.

StatusItemKindEvidence
doneFix the auto-reply guardThe Zendesk demand-processing leg is being deleted as bulk mail — including the April Defective Demand | Unable to File. The allowlist has one domain in it. Highest value per line of code found so far.donePR #5922
+ prod config stamped
doneMailbox sweep scriptRead-only Graph sweep into a local corpus. 15,112 messages, Jan 2024 → today.donePR #5889
doneTag taxonomy + rules1,477 collections messages classified, 91.8% deterministic, 96.8% measured precision. tag-rules.json is the parser spec.donelocal corpus
tag-rules.json
doneAttachment sweep — pass 2Delinquency Report, Eviction Filing Report, Results of your Cases and Writs at the Sheriff all carry the per-resident rows in a PDF/XLSX. We captured filenames only, so the four highest-value emails are pointers to data we do not have.newPR #5920
doneAssistance ingestionTwo programmes: Brothers Redevelopment (TRUA/RUMA) and CED Project (CERA, $10k cap, 90-day cheque expiry). Applied · docs requested · commitment · approved · paid · denied.newPR #5939
assistance.ts wired into extract-operational-signal
doneStop-process detection20 recorded "cancel the eviction, assistance is coming" messages, 4 after a writ was at the sheriff. Nothing today can hear one.newPR #5939
stop-process.ts on main
doneLaw-firm ingestionTschetter Sulzer on BOTH domains — thslawfirm.com and tsm.law — plus their Zendesk. Key on Client 54784 + case/ticket, never on a name.newPR #5939
law-firm.ts, both domains
doneSignals → stage write pathThe ADR-0097 amendment is Accepted as of 21 Aug, so this is unblocked. Stop-more facts auto-write on a linked case; release-direction transitions stay proposal-only; an unlinked case writes nothing but still alerts.newCaller BUILT + 20 tests + PR #6107. EXERCISED END-TO-END ON THE WILLOWS BENCH: human link → law-firm parse → assistance stop → stage assistance_approved persisted by collections-parser, verbatim chronology row, actor unattributed. Flips to done on merge.
doneFix category driftA $10,000 rental-assistance payment confirmation is filed as vendor_cost/info. Filtering on delinquency, eviction_legal loses about a quarter of collections traffic.fixVerified on prod subjects: assistance → delinquency, law firm → eviction_legal, via collectionsSignalCategory. The deterministic parse overrides the LLM (deterministic.category ?? parsed.category).
doneShip the benchmarked extractor promptConfirmed unshipped by hash. A prior session measured category 0.667 → 0.857 on clean gold. That number is not re-checkable — see the evidence.fixPR #6179 pins the instrument.
MEASURED — the prompt does not ship. The win is unverifiable forever: zero goldens carry category, and the corpus that produced 0.667 is gone and unrebuildable. The hazard is measurable, and it reproduces — 7 repeats × 65 cases × both arms. An unverifiable win does not outrank a reproducible regression.
doneJoin signals to the dun queue77 queued messages know nothing about active court cases or $50k of assistance in flight. A chase could go to a unit whose cheque already cleared.newDone — both halves joined. (Status was still “in flight” until 27 Aug: this row carries no PR or block link, so the deriver cannot reach it and the authored value is a remembered fact. Re-verified in code before flipping it — caseStateNote renders at review/page.tsx:868.) Stop signals were already there (describeStopSignalstopSignalNote). Court-case state is joined too, and this row said otherwise for longer than it was true: caseStateFactdescribeCaseStatecaseStateNote on the review row, rendered on the queue itself (review/page.tsx:845), with the read wrapped so a failed lookup drops the note rather than the row. So a reviewer approving a reminder does see an active case before they click. Re-verified in the code 26 Aug after the status and the evidence here disagreed.
doneMap AppFolio's PaymentPlan columnRead off the wire and thrown away. A resident on an active arrangement is invisible and fully dunnable.fixisAppfolioPaymentPlanActive reads BOTH column shapes (money form + Yes/No) → onPaymentPlan on PMSCollectionsAccountStatusRowrow-stage.ts.
doneMake payment_plan stage gate enrolmentMarking someone "Payment plan" does not stop the cadence today.fixCOLLECTIONS_STAGE_HOLDS_CHASE.payment_plan = true; enrollment.ts excludes on the PMS flag with its own counter + summary line.
done/collections — renewals shapeAging cards up top, then one table with three tabs (Requires action · In progress · Resolved). Show all 84 accounts with both totals.FedePR #5886
doneZero-balance ratchet$43,683.70 of already-paid debt frozen on the page. Must land before showing all accounts.FedePR #5884
doneDemand builderDate math + AcroForm fill + cash-assistance inquiry. Pulled into Phase 1 by decision 3. 2,599 lines, uncommitted.FedePR #5930
operator surface + legal gates
doneADR-0097 amendmentPermits collections stage transitions from email signals, with the identity bar relocated — a human links a case ref once, later messages inherit it deterministically. Stop-more auto-writes; release is proposal-only; unlinked still alerts. Status: Proposed.donePR #5938
doneSweep argument validationRefuses a reversed window at the argument instead of reporting it as a short corpus three steps later, and the three attachment states stop collapsing into a boolean that asserts the unverified positive.donePR #5935
doneEmail parsers — law firm + assistanceDeterministic, ported from tag-rules.json. 844 claims over all 15,112 messages at 100% flow precision, 86.7% recall. Customer identity lives in Property.collectionsCorrespondents; absent config is inert, never lossy. Inert everywhere until Camellia's row is stamped.donePR #5939 merged
doneCustomer-constant fence3,102 files across src/lib, src/app, agents, lambda. Nine machine identifiers. Six non-vacuity proofs, each watched fail. Starts with a 21-row dated allowlist that can only shrink.donePR #5974 merged
doneScrub resident names from sourceReal resident names in a code comment and test fixtures, confirmed against the mailbox corpus with both controls.fixPR #5977 merged
doneADR accepted + deferred fixes folded inThe amendment moves Proposed → Accepted, and the six items each lane consciously left after earning a clean verdict are paid down rather than left as debt.fixPR #6015 merged
doneAppFolio trusted sender per propertyFence row 1. The gate authorising notice-to-vacate and countersign side-effects trusted one customer's mail domain. Also closed a live cross-tenant path — unit-number-only matching could land a notice on the wrong property's resident — and an anchoring hole logged as a held P0 in July.donePR #5979
ceiling 21 → 20
doneTenant detail — collections panelGraph strip showing where they are, plus the chronology with assistance and lawyer rows from the inbox.newPR #5930
TenantCollectionsPanel on main
done/collections — Stage replaces StatusOne derived delinquency-lifecycle Stage from the same deriveCollectionsLifecycle graph the tenant rail draws. Status column deleted, the field kept as a mark plus tooltip. Precedence: depth decides, except a state that stops a track outranks every state on the track it stops.newPRs #6022 · #6052 · #6060
Stage cites the message behind it

Phase 2 · Talk

PHASE 2

Turn on the resident conversation

first real send happens here

The queued cadences start going out, one hand-picked resident first. The hard part is not sending — it is handling what comes back.

StatusItemKindEvidence
not startedPick the first residentSmallest balance, online payer, English, no dispute history — and cross-checked by hand against the signal feed, because no code does that join yet.newDone 25 Aug. Gera picked him himself and named the tenant page. Withdrawn on 23 Aug — "dont text a resident, just mark as done if tested up until send" — because choosing a canary only mattered for a send that was not being made. That changed on the 25th, and the pick was then cross-checked the way this row asked for: the fresh touch re-read AppFolio live (3,682 charge rows) and re-applied every stop before composing.
doneThe first real sendWatched end to end. Decision 1.new✅ Done 25 Aug, 19:57 MDT. Watched end to end: captured 01:45:44Z, approved 01:57:07.976Z, on the wire 01:57:08.473Z — 497 ms — and Twilio reports delivered, error_code: null, 2 segments, from Camellia's toll-free line. Row carries the provider's own id, and nothing writes sent except the send path after the outbound call returns. Withdrawn on 23 Aug when the bar was re-cut at the send boundary; un-withdrawn because it then actually happened. It rode the property-clock gate from #6268 and the rent disclosure from #6260, both merged the same day.
doneReply classificationNine intents. Three carry hard stops we currently violate: hardship → never negotiate; dispute → never argue and pause; maintenance → statutory defense.newPR #6178
Nine intents; abstention is first-class, and a classification may only ever add a constraint.
doneEmail must stop a dunThe reply gate is phone-keyed; email conversations are email_user:-keyed. An emailed hardship stops nothing today.fixPR #6176
The stop now reads the person, not the phone — so the email thread counts.
doneLanguage preference as a gateClara already composes Spanish for this cadence, but the dun path never resolved the resident's stored preference — so a Spanish-preferring resident got English, and nothing recorded that it had happened.newPR #6205
Language and where it came from reach the reviewer, so a silent English fallback is visible rather than invisible.
doneQuiet hours on the property's clockDecision 4, REVERSED. Gera settled it on 2026-08-25: ONE standard, the building's clock, and a missing time zone REFUSES rather than falling back to a guessed one. ADR-0129 (the resident's clock) is superseded; ADR-0125 §4 is live again.fixMerged 25 Aug (#6268, d17dcc58) — with the counsel and ADR sweep in #6286. The send path reads Property.timezone and stops when it is missing, so nobody is texted on a zone we guessed. Contact hours are 8:00–21:00 at the BUILDING, not at the reviewer. The area-code resolver stays for other lanes; collections no longer uses it. ✅ Both halves now proven against production, four hours apart. 19:57 MDT: approved, sent, Twilio delivered. 23:21 MDT: a FRESH capture approved during quiet hours was refusedreason: quiet_hours, "It is 23:21 at Camellia Apartments, and reminders only go out between 8:00 and 21:00 local time there" — nothing sent, and the row stayed pending rather than burning to a terminal state, because a transient condition is retryable by design. The refusal names the BUILDING, which is the whole point of the reversal. (The test row was then rejected, so it cannot become a second text in the morning.)
doneEscalation upwardOwner and regional-manager alerts — "eviction danger", "decision needed" — with a recommendation attached.newPM tier: PR #6205 — the alert now says what the resident actually said.
Owner tier: decided and built. Gera, 2026-08-24: "escalate to owner — I think that’s fair." Scoped to the two deepest lifecycle states only, "the points where an owner’s money and legal exposure genuinely change".
#6226 — the stage-change trigger. Six of seven stages alert nobody. Review killed the first delivery design: reusing the escalation matter would have texted the owner’s reply to the resident.
#6227 — a served demand becomes a recorded act, so a cure deadline is computable at all.
#6229 — the "went uncured" predicate.
#6237 — the daily pass that notices it, and the marker that stops it telling the owner again every morning. Merged. Three review rounds; the two that mattered were places the PR body described behaviour the code did not have — a property that had named no owner would have paged a red run every morning forever, and the population was case rows rather than tenancies, so “served, didn’t pay, moved out” — the ORDINARY end of this stage — would have emailed an owner about a resident already gone.
#6244 — a sweep that examined NOBODY must not report ok. The pass catches per-property throws so one bad row cannot abandon the fleet, but nothing read those counters — so a run where every property failed reported green, which is indistinguishable from a run that found nobody. That is the failure this job exists to prevent, wearing the success it exists to report.
Measured on prod before and after merge, read-only: 0 accounts at notice_to_pay_or_quit, so the first run sends nothing and cannot until an operator records a demand as served.
Regional-manager tier is still unbuilt — only the owner tier was asked for.
doneCarrier paperwork for rent messagingNot 10DLC. Camellia's number is TOLL-FREE, so Toll-Free Verification governs it — and that verification is APPROVED, for a use case that never mentions rent or balances. The two failed 10DLC campaigns are a separate, unused registration.fixNot the blocker it looked like. The disclosure half shipped 2026-08-25 (#6260, live): the privacy policy and consent page name rent and past-due balances in all five places. Then the first rent text went out on this very number and the carrier reported delivered with error_code: null — no 30032, no 30034, nothing blocked. ⚠️ Do NOT "fix" the registration casually: Twilio has no edit-in-place for an approved toll-free verification, so changing the use case or opt-in flow means delete-and-resubmit, and the number then sits in Pending, which cannot send at all — maintenance and work orders included — until re-approval at the back of the queue. Content changes need no resubmission. What genuinely remains is the opt-in-provenance mismatch (declares WEB_FORM; the real source is the number the resident gave the property), worth a planned window with that downtime accepted.

What "done" would take — a choice, not a task. Either (1) do the resubmission: delete the approved verification, submit the amended use case AND the corrected opt-in provenance, and accept Camellia's line being dark for the whole review — the only path that makes the registration actually describe the traffic; or (2) close it risk-accepted: the resident-facing disclosure is correct and live, renewal traffic already rides this number under the same summary, and a real rent text delivered clean. It does NOT gate Phase 3 — nothing in Phase 3 sends SMS from this number. Measured and written up in #6266; the correction that resubmitting is the dangerous move is in #6286.
doneExpire stale captures on the review queueA reminder may only be approved within 48 hours of being written; past that the send path refuses stale_capture. The queue did not know that, so 77 un-sendable rows sat labelled "Waiting for your approval" with a button that could only refuse.fixMerged 26 Aug (#6291, eab39ff6) — live on main. Expiry is DERIVED from the same constant the send path refuses on — never written to a row, because nobody decided it and a stored copy could disagree with the gate. Adds an Expires column with the real deadline, hides expired rows by default, and marks the filter pill whenever rows are being withheld (the pattern the conversations log already uses for dead air). Nothing is deleted. Three review rounds: the first shipped a filter that never ranisFilterApplied is value !== unappliedValue, which falls back to the DEFAULT, so a filter whose default IS the narrowing value reads as "not applied". Six green unit tests, a pill announcing "77 hidden", and 77 rows rendering underneath it. Fixed, and now pinned by a mount test whose mutation control reddens 3 of 6 cases without the fix.

Phase 3 · Do the paperwork

PHASE 3

Clara as a coworker

per-property · counsel only for payment plans

The repetitive human work disappears. Joanna signs instead of filling; Clara handles the assistance packet she does every week.

StatusItemKindEvidence
in flightMagic-link signatureExpiring link, no login, one tap to approve. Not greenfield: calendar/tour-slots.ts already carries the signed-token pattern this extends.newBuilt 27 Aug — #6399 open. The row previously said “greenfield — there is no tenant authentication of any kind today” and “nothing is written yet and no branch exists”. The first was wrong when written: domain/calendar/tour-slots.ts already argues every property this needs, including the token’s KIND riding inside the signature with a fail-closed default. What shipped: a signed token module, a one-tap page, and a read-only mint script. The token is not an actor — it carries no authority and mints no session; the page runs the ordinary auth gate, so a click needs BOTH the link and a browser that has already signed in. A forwarded link lands on the login page. Approving POSTs to the same decision route the queue drawer uses, so the atomic pending→approved claim, stale-capture, quiet hours, the stop signals and property∩org scope all run unchanged — a new door, not a new authority. Not yet verified: the real click-through on the bench property needs a deploy, and COLLECTIONS_APPROVE_HMAC_SECRET must be set in production before any link is minted there (minting refuses without it rather than falling back to the dev secret). Still open: nothing in the product tells a PM a reminder is waiting — the queue is a page they visit — so there is no carrier for the link yet. That channel is its own decision.
donePDF generationNOT greenfield — pdf-lib is a dependency and the demand-notice PDF already renders. What is missing is the fields it prints.newUnblocked 27 Aug — p3g shipped (PR #6371), so a property with demandParty set now produces a real, filled, flattened PDF end to end. No engineering left on this row; what remains is per-property data entry (below), not code.

⚠️ Corrected 26 Aug — this row was wrong, and it was wrong in the direction that overstates the work. It said "no PDFKit, puppeteer or jspdf anywhere", which is true of those three libraries and misleading: pdf-lib ^1.17.1 IS a dependency, and /api/collections/[tenantOccupancyId]/demand/pdf already fills and flattens an embedded template. Its refusal is the good part — fillDemandForm throws on an unservable packet and the route does not pass allowUnservable, so no file exists to download when the checklist is unsatisfied. There is no query param or role that overrides it.

So PDF generation is BUILT. What is missing is p3g — the two required party fields it would print (landlordName, remitTo), which is why that lands first.
doneAssistance packet auto-fillThe same form + tenant ledger + I-9, every week. Clara fills, Joanna signs, we submit, Clara answers the programme's follow-ups.newWithdrawn 25 Aug — Gera asked what it would take before committing, and the answer is why it is out of scope for now. It is not one feature: it is filling a third party’s paperwork (Brothers Redevelopment runs TRUA/RUMA, CED Project runs CERA), which needs the form itself, a tenant ledger export, an I-9, a signature (that is p3a), a PDF writer (p3b) and a submission path — then Clara holding a multi-week email thread with a caseworker who asks follow-up questions. We already read that mail and extract facts from it (#5939), and a commitment letter already stops a cadence — that is the valuable half and it is done. The rest is form-filling on behalf of a resident, against a programme whose forms we do not control. Deferred, not rejected: revisit after p3a and p3b exist.
withdrawnAuto-sign + auto-printFede's level two and three: stamp the stored signature, then print to the office printer so she walks in to completed documents.newWithdrawn 25 Aug — Gera: "I don't think we should do payment plan right now or the auto print." Not rejected on the merits, dropped from this scope. The signature half depends on p3a anyway.
withdrawnPayment plansPolicy-bound, with PMS write-back so AppFolio stops contradicting Clara. The one capability that genuinely needs a lawyer first.newWithdrawn 25 Aug — Gera: "I don't think we should do payment plan right now." ⚠️ Also the one item the counsel skip did NOT cover: ADR-0117's research says accepting a partial payment can waive eviction rights on a notice, so it wants a lawyer before it un-gates. Deferred, not rejected.
doneOwner reportingDelinquency trend and recovery rate — the case study Sean wants.new✅ Shipped 26 Aug#6333, squashed to 4a795b20 on main. The weekly owner report now answers how much of the debt came back, not just what is outstanding: Past due 30+ days with a MONTH-over-month baseline (a weekly delta on a monthly sawtooth mostly reports where in the month you are), and Recovered in <month> over the residents who were delinquent when the window opened.

Deliberately high-level. It briefly carried a per-door table of the eight largest balances and that came out the same day — Fede "this might be too granular", Trinity "keep it to a single line … let anyone who wants the breakdown drill into the dashboard", Gera "perhaps more highlevel only". Two figures and a link to /collections, which carries stage, balance and history per resident AND stays current between sends.

Six review rounds; the two that mattered were both fabricated-number bugs. A property with aged balances and no archived receipts rendered Recovered in July: 0.0% to an owner — the module applied its own absence rule to the denominator and not at all to the numerator. Proven on live prod data by withholding the receipts row: pre-fix 0.0% of $20,514 over 16 residents, post-fix the section is omitted. And the value-colour change silently repainted a SECOND email (the weekly leasing report) through the shared metricsTable; the 12-shard suite caught it where a grep-based containment check had said it was contained.

Scoped 26 Aug — and it is much smaller than "build owner reporting". The weekly owner report already exists (/api/cron/leasing-report, Mondays 15:30 UTC, emails each property's owner recipients) and already carries delinquency: delinquency0to30 / delinquency30Plus from persisted AppFolio aged-AR, current residents only, rendering a missing snapshot as "—" rather than a fabricated $0. The TREND half is already shipped toocomputeWeekOverWeek produces totalDelinquencyDelta and delinquency30PlusDelta against the prior SENT report, and the email renders "Past due 30+ days" with that trend and a tone. So the only genuinely missing piece is recovery rate.

⚠️ Measured, not assumed, 2026-08-26. Recovery cannot be computed from charge_detail: PaidAmount is per CHARGE while ReceiptDate is a comma-separated LIST, so a charge whose receipts straddle a window boundary cannot be split. Live at Camellia: 23.0% of rent charges carry multiple receipts, and by window span the unattributable share of paid dollars is 49.8% at 7 days, 35.6% at 30 days, 8.1% at 90 days. A weekly recovery figure from that source would be a guess half the time.

The right source already exists. PaymentHistorySnapshot / PaymentReceipt (from receivables_activity) is a per-receipt ledger — exact txnId, ISO receiptDate, exact amountUsd, payer occupancy, no reversals. Verified live: 143 property-months at Camellia, 2010-08 → 2026-07, ~140 receipts and ~$127k–$138k a month across ~90 payers. Both halves of the ratio are therefore already persisted and readable. Two constraints to design around: the archive is per-MONTH (so the figure is a trailing complete month, not the current week), and the numerator must be scoped to residents who were delinquent at the window start or the ratio exceeds 100%.

What is left is a definition, not a build — see the open decision.
doneA home for the two required demand fieldslandlordName and remitTo are REQUIRED by the demand API schema with no default, and neither is stored anywhere — so both are retyped for every notice.newShipped 27 AugPR #6371, merged. Property.demandParty is the home; demand-context.ts:219 reads it via resolveDemandParty. Nothing is inherited or derived — an operator states it per property via scripts/set-demand-party.ts, fail-closed when absent (identical to pre-change behavior). Unblocks p3b.

Surfaced 26 Aug by audit, not by a failure — nobody has hit it because no account has reached notice_to_pay_or_quit yet.

⚠️ Two fields, not four — corrected after checking each schema modifier rather than the docstring above them. demand-context.ts lists FOUR things it will not resolve, and that list is about resolution, not about what the caller must supply. Read one at a time: agreementType carries .default('residential') with a sound argument (the other variants have SHORTER notice periods, so the default can only grant more notice than owed), and municipality is .optional() — omitted asserts "no municipal rule applies". Only landlordName and remitTo are required with no default.

Neither is stored — zero hits in types.ts, verified 26 Aug. So both are retyped for every notice, on the one document where a typo is legally material, and they are stable per-property facts. That makes this the Property.operationalDataSenders shape exactly (CLAUDE.md worked example): a per-property row, fail-closed when absent so a property that has not set them is unaffected, a dedicated writer script, and an explicit product-vs-customer line.

demand-context.ts is RIGHT to refuse to derive them — "deriving them from the nearest-looking field would be a fabricated value on a legal instrument". Storing what an operator stated is not deriving.

It gates p3b — a PDF of a notice with blanks is not a servable document.
not startedInbound email throws attachments awayEvery inbound path hardcodes attachments: [], so a law firm’s PDF results report can never reach any parser — and a PDF with no body text is recorded as a BLANK email and skipped.fixVerified 26 Aug, prompted by Fede asking whether we can parse collections updates from PDFs. Five hardcoded attachments: []: four in email/webhook-processors.ts (:436, :552, :667, :1353) and one in process-email-record.ts:316. That file states the consequence itself: "attachment-only mail is indistinguishable from empty mail here; that is a pre-existing ingestion gap" — such a mail is skipped as blank_inbound_body.

Both halves of the parse already exist and neither is reachable. extractPdfText() is live and used by the invoice parser, the quote parser and the rent-roll reader. extractCourtOutcomeCodes() already knows the Results-Report vocabulary INCLUDING D-J-POSS, with COURT_OUTCOME_LEGEND carrying the same plain-English meaning a human typed out by hand in Slack on 26 Aug. Nothing in production calls it — it is exported and covered by tests, and that is all. Its own docstring names the gap: "the codes live in the ATTACHED report, not the email. Feed it extracted attachment text when you have it."

⚠️ Blocked on a real sample, not on effort. A per-row read is what makes this actionable — which UNIT got which outcome, not which codes appear somewhere in the document. The row format is undocumented: types.ts cites ATTACHMENTS.md for it and that file does not exist in the repo; nothing under docs/ mentions D-J-POSS. Inventing the line shape would be a fabricated premise on a legal-status feed, so this wants one real report to read first.

Two calls to make when it is built: a parsed code should PROPOSE case state rather than write it (a default judgment for possession is heavy to set off a regex over OCR’d text, and the release-direction rule here is already proposal-only), and an UNRECOGNISED code must surface as "look at this" rather than silently as nothing.
not startedPre-due payment reminder (preventative)Everything above reacts to a balance that is already past due. This is the opposite: a nudge sent BEFORE the due date — "rent is due in a day or two, disregard if already paid" — so it can head off delinquency rather than chase it.newGera, 27 Aug: "initially it's turned off... I want to make sure it's wired up and everything's there."

Verified 27 Aug: does not exist today, anywhere. collectionsChaseWorkflow's own doc comment gates entry on “a tenant is at least a full month behind on rent” (the enrollment walker's threshold, COLLECTIONS_MIN_BALANCE_MONTHS = 1 in constants.ts), anchored at enrollment — which itself only happens once already past due. There is no due-date-anchored timer anywhere in src/lib/domain/leasing/collections/ or the Temporal workflows; every touch in this system is a reaction to a missed payment, never an anticipation of one.

Shape, if built: a new anchor (days BEFORE the AppFolio-derived due date, not days after enrollment), reusing the existing send/stop machinery this tracker already proved out — quiet hours on the property's clock, the reply/stop gates, the human-approval queue — rather than a new send path. Ships disarmed by default per this request, the one deliberate exception to the repo's own "no arms by default" standard (CLAUDE.md hot rule 13) — reasonable here since the ask is explicitly for a dark-by-default preventative nudge, not a shipped-on feature nobody remembered to gate.

Not scoped or estimated yet — flagged here so it doesn't fall through a phase boundary.
not startedDemand letters "bake" in Slack before printingRight now a generated demand notice is only visible inside the product. Post it to the Camellia Slack channel when it's ready so the team can confirm before anyone prints and serves it.newGera, 27 Aug founders standup: "whenever we have the need for demand, probably just post it there... Clara will send it to Agent Smith and post it like, hey, we have a demand ready, let's go ahead and print it... let it bake, confirm."

Not scoped. Same shape as the approve-link Slack carrier that shipped the same day (PR #6423) — likely the same notify() plumbing, posting to the property's leasingActivityChannel rather than a hardcoded channel, for the same multi-tenant reason that PR's review caught. Depends on p3b/p3g (PDF generation is done, but a real property still needs its demandParty facts set before there's a real PDF to post).

Standing risks

These are not tasks. They are things that stay true until someone changes them, and any "done" above should be read against them.

Sibling page: Collections — Three Phases — the strategy, the measured journeys, the lifecycle graph and the glossary.

PropFlow Docs