Renewals — the one doc

Last refreshed Oct 23, 2026 · renewals lane · nothing new is on at any customer

Where we are: Clara has run renewal outreach at Camellia since April. The paperwork engine is built and proven at our test property, the Willows, all the way to the tenant's signature. Turning anything on at a customer is Fede's call.

On this page

  1. The vision
  2. What exists today
  3. Where each customer stands
  4. What's next, in order
  5. Open follow-ups and what is not proven
  6. Links
  7. History and research (September, folded)

1. The vision

The PMS (AppFolio) system of record leases, rent, signatures Clara system of work list, offer, chase, packet reads parks, sends team the office does the paperwork prepare Clara parks it, office sends clara Clara sends, office countersigns Three levels per company. Each step up is the customer's call.

The PMS stays the system of record; Clara becomes the system of work. In the Western Slope deck these are Levels 1 to 3.

2. What exists today

What exists today

Clara's outreach: live at Camellia since late April

Camellia, late April to Oct 23Count
Leases that came due57
Offer sent by Clara (text letter, call or text)32
Of those 32: never replied, yet still decided19
All 57: signed / notice / month-to-month / transfer / left25 / 19 / 6 / 5 / 2
Negotiated anything4

Lesson: the offer itself is the conversation. Having the paperwork ready on time matters more than more messages.

The paperwork engine: built, proven at the Willows

Recipeper company,versioned Buildfills every blank,month-end term Readan open offerwins, no write Parkunsent, pageread back SendClara sendsfor signing Tenant signsseen in one sync,reminders stop

Green = proven on a real AppFolio lease at the Willows. Tenant signature proven Oct 23, 10:09 AM Denver. Then cancelled and reset.

  • Before: the office typed every renewal packet by hand. After (merged, dark): Clara builds it from the company's own recipe, parks it unsent, and checks the finished page.
  • Western Slope's shape: their one-page renewal, run at the Willows, parked correctly. Replayed on 130 real Western Slope leases: 130 would have held before the fix, 0 after.
  • New leases, same engine: Camellia's new-lease shape, at the Willows: a real approval parks the lease and the office gets a note 5 seconds later. Two co-applicants on one unit: one lease parked, the other held with a plain reason.
  • The learner: read three customers' past paperwork, read only, and drafted each recipe plus the office's questions. Western Slope matched our hand-written recipe; Situs: 27 blanks, 23 questions.
  • Robot verbs: read, park, send the parked offer, cancel; every answer is one of five plain outcomes. Found and fixed Oct 23: the tenant's portal login now follows an email change, and cancel works on a tenant-signed offer.
3. Where each customer stands

Where each customer stands

CustomerOutreach
leasing.renewals
Renewal paperwork
renewal.paperworkSender
New leases
newLease.paperworkSender
CamelliaLive since Aprilclara: the older letter path (robot fills their PDF form). The recipe engine is not used thereteam (off)
Western SlopeOffunset = team. Deck ready; supervised week pending Fedeteam (off)
Situs GroupOffunset = team. Recipe drafted by the learnerteam (off)

Why Western Slope first (their AppFolio, Jul 24 to Oct 23):

Today, by handNumber
Leases due / got an offer26 / 15
Offer sent before lease end, median47 days
Offers out by the 90-day mark0
Offers rebuilt at least once7 of 15
Due in the next 90 days with no live offer14 of 25

When an offer does go out, 14 of 15 sign. The gap is getting the packet out on time.

4. What's next, in order

What's next, in order

  1. Present to Western Slope with the deck: Clara prepares the packet, their team presses Send.
  2. Supervised week at Western Slope (Fede's go): one test unit, packets in December for March leases.
  3. Turn-on per company, one step at a time: team → prepare → clara.
  4. Learner becomes the onboarding product: a new customer's recipe from their past paperwork. Situs is the test.
  5. Countersign tracking and the office nudge.
  6. Yardi as the second PMS.
  7. Rent recommendations.
5. Open follow-ups and what is not proven

Open follow-ups and what is not proven

  • #22053 Which co-applicant builds the lease depends on the order approvals arrive.
  • #22054 Our copy shows one Camellia application approved; AppFolio says decision pending.
  • #22066 Resetting a renewal leaves the packet note behind (harmless to a re-run).
  • #22021 Pin each new-lease run to its approved application.
  • #21982 Retry short failures before sending the office note.
  • #21983 Plainer results and logs on the new-lease office step.
  • #21978 A timeout test leaves out the office-note step.

Not proven yet: countersigning (we never countersign a test renewal); the office countersign nudge (silenced for sandbox companies on purpose); approvals that arrive at different times.

Waiting on Fede: the supervised week at Western Slope (recommend before December). The "prepare" level applies only once a company is Live (recommend yes). An approval in AppFolio counts as the human Send (recommend yes).

6. Links

Last refreshed Oct 23, 2026.

September 28 snapshot (superseded by the sections above)

Kept for the record. The plan below (Western Slope live by Oct 2, the robot unable to make their offers) is no longer current.

Renewals status, the plan to Western Slope paying 2026-10-02, and Fede's gates.

2026-09-27 · Western Slope first, Camellia keeps running

▶ Listen to the podcast episode (20:02, covers the research below)

Newer: Renewals, end to end: levels of autonomy (2026-10-12) is the design going forward; where the plan sections below disagree with it, it wins.

1. Where we are (2026-09-28)

  • The workflow already does the simplest mode at Camellia: offer text from the AppFolio offer, follow-ups, nudges, renewed on countersign. Nothing new is designed.
  • Built overnight, all dark: company-level renewal policy homes inherit; renewal texts hold for review in the one existing Slack queue; robot stops guessing templates; seven Camellia hard-codings fixed live.
  • Sandbox dry run over their real leases: after two fixes, 13 leases would start today, 25 by +90. The two bugs found and fixed live (eligibility ignored the company id; it burned the AppFolio request allowance). The company-id bug sat in eight more places, all fixed; Western Slope's collections page and renewal sweep had been failing on it since 09-23. Only 4 of 179 leases carry an AppFolio offer.
  • The robot cannot create their offers yet: it drives PDF-form renewals only; Western Slope uses AppFolio-native templates. Needs one recorded write (your call, gates).
  • Writes are already live for Western Slope in the robot; the renewal job is off.

2. Smallest turn-on at Western Slope

Existing workflow, existing queue, their settings, off in production until Fede turns it on.

Eight steps: four building tonight, one dry run, two settings steps, one gate. Open for the table.
#StepTypeProven whenStatus
1Company-level renewal policy (terms, rent strategy, month-to-month fee, eligibility thresholds, text-only) that every home inherits, with a per-home override; fields on the company page, empty by defaultcodeSet once at the company, overridden at one home, nearest value wins; Western Slope policy resolves for all 69 homesmerged: data + readers (#12897), staff save route (#12926), "inherited from company" on the property page (#12928); company page card reopened as #12996, auto-merge armed. No company has a policy yet
2Renewal texts and reminders go through the existing prospect-email hold when the company's renewals review is on (hold record learns a text draft; approve re-sends through the same text door); retire the temporary Camellia property switch into the company switchcodeA renewal text waits in the Slack thread, "ship it" sends it, "hold" stops itmostly merged: Camellia switch retired into the company switch (#12903), hold record holds texts (#12909), approve/reject a held text from Slack or the hold page (#12956). Open: the company switch + "offer and reminder texts hold when it is on" (#12977, review round 2), held texts on the Review list (#12958, armed). Proven in tests: held → "ship it" → sent once, word for word; held → "hold" → nothing sent. Gaps filed: Clara\'s text replies not held yet; no reminder for an undecided hold
3Robot picks the renewal template by rule, never by name guess: PDF-form properties use the account's configured template name exactly; AppFolio-native (APM) properties stop with a clear error; property defaults are NOT used (recorded data shows they pre-fill the new-lease package, not the renewal amendment). Western Slope's configured name fixed to the real titlecode (robot)Camellia still picks "Camellia Lease Renewal"; Pomona Park and Northridge dry runs refuse with the reason, nothing written#434 open, auto-merge armed; #435 (fills the amendment's hand-typed fields, not yet called) open
3bRobot learns AppFolio-native lease renewals (Western Slope's kind): prepare the offer with the RENEW (SIMPLE) amendment, fill date/term/rent, send for e-sign. Needs one captured real write of that flowcode (robot)One offer created at a test property through the native flow and the resulting document matches what staff sendblocked on where to capture — Fede's call, see gates
4Camellia cleanup: property timezone on renewal calls, no "Unit" on single-unit homes in texts, reminders and team emails, eligibility rule by state, unit optional when parsing AppFolio emailscodeRendered copy for a Western Slope house names the address only; timezone read from the property4 merged, live: property date on renewal calls (#12899), state-keyed renewal floor (#12902), unit-less AppFolio emails match the home (#12908), team notices name a house by address (#12912); reminders and board copy still open
5Sandbox dry run: Western Slope's real leases and tenants mirrored into the sandbox org, workflow run end to end with sends captured, list of what breaksproofEvery open lease produces a text with the right home, rent and term, or a named reason it did notrerun 09-28 on fixed code: 13 leases would start today, 25 by +90 — see dry run
6Western Slope settings: policy at the company (12 months standard, rent from the offer, $150 month-to-month fee, no opening call), 90-day window (already the default), renewals review on for week oneset valuesValues visible on the company pageafter 1
7Their AppFolio: set AF RENEW (SIMPLE) as the default renewal document on every property page, fix Northridge's default, fix the 21-day month-to-month notice in the WSPM leases. Their change; we bring the list to Wednesday's callWestern SlopeThe renewal flow pre-fills the document at one of their propertiesask
8Turn on: renewals module, autonomous renewals, write lock live, then invoiceFedeFirst offer text delivered and loggedgated

Left out on purpose: a board across all homes, Clara texting Kat, the charge feed into eligibility, emailing the offer document. All stay in the folded long-term order; none is needed to send the first text.

3. Fede's gates

  1. Where to capture the AppFolio-native renewal write (step 3b): (a) create one AppFolio-native lease template in JP&Co's database (Leasing > Lease Templates > New, no Blue Moon) and send one renewal offer to a Willows test tenancy ourselves, recorded — recommended, no client data, but it is a write to JP&Co's live database, so it needs your go; JP&Co has no native template today (checked 09-27, recorded captures); or (b) have Kat create one real Western Slope offer while we record. What we already have from Western Slope's page code: the full shape of the "prepare" call. Missing: its response, and the review-and-send-for-e-sign step. Without one of these the robot cannot create Western Slope offers and the office keeps creating them by hand.
  2. Turn renewals on at Western Slope: module switch, autonomous renewals, the robot's renewal job (writes are already live). Also: re-run the robot seed so the live config picks up the real template name instead of "TODO".
  3. The 4 leases ending 09-30: text a 12-month offer 2 days before lease end, or let them roll month-to-month and offer from there? Recommendation: skip the offer text, they roll to month-to-month under their lease, and they join the month-to-month decision below.
  4. Offer renewals to month-to-month and holdover tenants? AppFolio treats both as eligible; Clara skips them today (13 at Northridge, 36 holdovers, 20 of them at Courtyard with rents of $225–$948, possibly subsidized). Recommendation: yes for Northridge, hold Courtyard until the subsidy question is answered.
  5. Camellia month-to-month money: the live wrong charge on vacated unit 522; back-bill the two reversed tenants or not.
  6. Default template for Western Slope: recommend AF RENEW (SIMPLE) everywhere, an AF copy of the Pomona Park version, retire the four older amendments.
  7. The invoice — amount and terms.

4. Running tonight

Sandbox dry run over Western Slope's real leases (2026-09-28)

Sandbox dry run

Western Slope's 70 homes, 234 units, 179 tenants and 185 leases mirrored into the sandbox org; company policy set there (12 months, $150 month-to-month, no opening call, Camellia's v2 thresholds); renewals module on for the sandbox only; outbound kill switch on and verified before and after; 0 sends captured; nothing written to AppFolio or the real org. Fake phones and emails in the copy cannot reach anyone.

Rerun after the two fixes (09-28, 7 AM): every in-window lease now gets an eligibility verdict, all auto-renew under Camellia's thresholds (largest unpaid balance about one month of rent). Today: 13 leases would start, 5 held because the resident already said no. +30: 19. +60: 23. +90: 25. 25 live AppFolio reads, one per property; reruns replay from disk with 0 live reads. Still unchanged: holdovers take the month-to-month path, the 13 no-end-date leases are dropped with a log line. Correction to the first run: the offer is priced and prepared in AppFolio when the workflow starts, so "no offer today" is not itself a blocker; the blocker is that the robot cannot yet prepare an AppFolio-native offer (step 3b), so at Western Slope the first text would fail for no offer unless the office prepares them. New: (a) Western Slope has zero saved eligibility verdicts because the nightly job never succeeded there; the real scan holds everything until that job runs once on the fixed code (check Monday); (b) 4 leases end 09-30, so their first text would land 2 days before lease end with no 30/15-day reminders, a decision for Fede.

First run outcome: none of the 179 tenancies reached a text. In the 90-day window (18 leases in PropFlow's copy): 13 held with no eligibility verdict, 5 held as declined. 36 leases past their end date go to the month-to-month path. 13 Northridge leases with no end date are dropped with a log line and no one told, while the board shows them "ready". Only 4 leases carry an AppFolio offer; those 4 render correctly ("your lease at 3137 Perkins Drive, Grand Junction (Unit B)… $2,350/mo on a 12-month term"); the other 175 would be refused for no offer.

What breaks, ranked:

  1. Eligibility resolved the AppFolio account without the company id; Western Slope's credentials live under the company, so the lookup returned nothing and every lease was held. Fixed live (#13005). The same bug sat in eight more places; all eight fixed (#13015–#13022, four merged, four armed). Western Slope had hit three of them in the last 14 days: the holdover check (~2,900 failures), their collections page (~1,590), and the renewal reconcile sweep skipping all 70 properties every run since 09-23 (~10,800). That one owner record was 78% of every "no credential found" log line across all customers.
  2. Eligibility pulled a full year of charge detail for the whole property once per tenant; 11 tenancies in, the AppFolio request allowance was exhausted and the other 33 failed closed. The dry run itself used about 44 real requests from Western Slope's allowance. Fixed live (#13008): one pull per property per run; an exhausted budget is a named hold, never a silent decline. Dry run rerunning on the fixed code.
  3. 175 of 179 leases have no AppFolio offer, so no rate can be texted. Offers come from the robot once it learns AppFolio-native renewals (step 3b), or from the office.
  4. Leases with no end date are dropped silently while the board calls them ready.
  5. 18 vs 41 resolved: the data matches lease for lease. AppFolio's 41 = 18 fixed-term leases ending in the window + 13 Northridge month-to-month (no end date, same in AppFolio) + 10 already renewed (PropFlow carries the new 2027 end date). The 36 "past end date" leases are real holdover tenants AppFolio itself lists as eligible. Clara's renewal check skips both month-to-month and holdover tenants; AppFolio offers to both. Decision for Fede below. Three small sync findings handed to Smith on the bridge.
  6. Code comments on reminder timing are stale (says 48h/5d, runs 24h/7d). Cosmetic.

Rerun: the dry-run worktree's tmp-artifacts/wsr/rerun.sh; per-lease table saved with the session.

Western Slope's renewal templates (read 2026-09-27)

Western Slope's renewal templates

Read from their AppFolio, read-only, headless. Full text of every template saved with the session.

DocumentOffers, 12 monthsSigned
AF Renewal Function: RENEW (SIMPLE) — one page: new dates, new rent, all else unchanged3620
Older AMENDMENT – RENEW (SIMPLE)71
Add / remove tenant amendments62
Northridge RENEWALS (full new lease)143 (7 unsigned since late July)
  • Why "five": the older amendments differ only by who is on the lease (SIMPLE; add; remove; add and remove; a Pomona Park version with a $27 water minimum and a parking-permit waiver). The four "AF Renewal Function" copies replaced them for AppFolio's renewal flow; the only change is the start date fills itself. There is no AF copy of the Pomona Park one.
  • Staff type by hand: today's date, original lease date, new end date, new rent. The robot fills these for every offer.
  • Terms in their leases: 12 months standard, shorter terms to line up end dates; $150/month month-to-month fee; tenant notice 30 days; ending a month-to-month is 21 days in WSPM leases and 30 at Northridge; late fee greater of $50 or 5% after day 7; no reply deadline anywhere.
  • Their renewal settings: month-to-month option on, portal give-notice on, Renew by Default off, e-sign in use (Jay countersigns); every property's renewal option is 12 months, $0 change; default renewal template blank on 99 of 100 properties, Northridge points at its new-lease template.
  • Rent changes signed in 12 months: 10 of 19 kept the same rent; the rest rose $25–$175.
  • 90-day window on 09-27: 41 leases (the 43 was the 09-08 pull): 13 can renew, 4 out for signing, 13 month-to-month at Northridge, 1 gave notice, 10 already renewed.
Approval queues today and the consolidation order

Approval queues

Five places where a Clara message waits for a human. The prospect-email hold is canonical: Slack thread in the property's leasing channel, "ship it" / "approve" / "go", "hold", "reject: why", anything else is feedback and Clara redrafts; also on the admin hold page and the Review tab; company admins and PropFlow staff approve; bumps at 1h and 3h. It is email only. Past-due texts have their own Review-page queue with no Slack. Camellia's escalation hold uses the same record under a separate temporary property switch. The renewals board has its own offer approval. The team email when a first email did not go out (#12411) is a second alert next to the Slack post.

Consolidation order, one concern each: retire the Camellia property switch into the company switch; hold record learns a channel and a text draft; approve re-sends a text through the dispatcher; Slack post and pages render a text draft; a review setting per module on the company page; renewal sends create a hold when renewals review is on. Later, Gera's past-due text queue moves onto the same hold (goes to Smith first) and the two "did not go out" alerts become one.

Camellia hard-coding found in the renewals code

Hard-coding

  • Renewal calls pin "today" to Denver time; the comment already calls it temporary. Use the property timezone.
  • Lease term falls back to 12 months when no policy exists. Fixed by the company policy.
  • Default eligibility rules (2 months balance, 2 late payments) apply wherever no policy exists. Same fix.
  • The Colorado minimum rule is always applied. Key it by the property's state.
  • Team emails, reminders, escalation copy and the board rows say "Unit X at property". Drop "Unit" for single-unit homes.
  • Unit is parsed from AppFolio emails by splitting "Property - Unit"; houses get nothing. Allow a missing unit, match on address, test on a real Western Slope email.
  • The robot picks the renewal template by the name containing "renew", preferring "12 month". Camellia has one template; Western Slope has nine. Use AppFolio's property default.
  • Not found anywhere in live code: $150 month-to-month, 30-day notice, increase percentages. Camellia's month-to-month is $100 in its own settings.

History and research (September, folded)

Older research and history, kept for the record. Where it disagrees with sections 1 to 6, sections 1 to 6 win.

Where we are today — the full status list

Where we are today

  • The engine is sound. Temporal is healthy — 16 running renewals, none past lease end, zero non-determinism — and the workflow-level lab replayed the real Camellia renewals with no crashes at all (0 of 66 threw).
  • The channel is decided: text first, no opening call. 29 calls reached 2 residents and produced 0 decisions; every decision we have was made in a text thread. Email carries a copy of the offer later, not first.
  • Merged tonight: the renewals specialist on the phone line now checks the module gate (#11306), and the stale renewal planning docs are gone (#11350).
  • In review, one concern each: never text a price when no offer exists; one renewal per lease per cycle; call outcome read from the transcript; mark renewed when AppFolio shows the countersignature; a failed offer becomes a PM task instead of silence; the escalation tool never tells a resident to call the office; bounded executed-lease polling; quiet hours in the property's own timezone.
  • Running right now: the change that makes every renewal message name the home it is about, and an end-to-end lab run over Western Slope's real data.
  • Western Slope's AppFolio is still read-only. Turning writes on is one setting on their account row in the browser agent — Fede's flip, no build, nothing for Western Slope to do.
  • Western Slope lab run done: data flow works end to end; blocked only by per-property config (policy, text-only switch, eligibility thresholds) that has no company level yet. (Lab C)
Camellia month-to-month rollover: root cause (2026-09-24)

Camellia month-to-month rollover: root cause

Root cause. When a Camellia lease rolls to month-to-month, PropFlow backdates the new rent schedule to the day after the old lease ended. By the time the daily scan runs, AppFolio has already posted that month's rent, so the new charge never bills — it only edits a future recurring schedule, and AppFolio never bills a month it already posted. The office email then says the rollover is done without ever checking whether AppFolio actually confirmed a charge that will bill.

Second bug. The same code also skipped the check for a tenant who had already moved out, so a vacated unit still got rolled to month-to-month with a live charge attached.

Blast radius. Three Camellia tenants, units 420, 504 and 522, all converted the same day. None was ever billed the new rate. The office found and reversed two of the three by hand; the third, on the vacated unit, is still a live wrong charge waiting to come off.

The three fixes (bug fixes, ship live tonight, no setting):

  1. Make the rent write actually bill the month it claims — either bill the next real posting date with a catch-up charge for days already passed, or add an explicit one-time charge for the current month.
  2. Stop the office email from claiming a rollover succeeded until it reads back a confirmed, billing charge from AppFolio.
  3. Fix the move-out check so a vacated unit can never be rolled to month-to-month.

The auto-convert setting (default OFF, set at the company with a per-property override) comes after the billing fix lands — a switch that doesn't actually charge the tenant is worse than no switch.

Fede's two calls: whether to remove the still-live wrong charge on the vacated unit, and whether to bill the two reversed tenants the back-difference or let it go.

QA pass: inbound renewal call and text (2026-09-24)

QA pass: inbound renewal call and text

Real calls and texts into the Willows test line, test tenant only, no real residents touched.

ScenarioVoiceText
Basic questionspartialpass
Caller/texter not on filefail — 99-call loop, droppedfail — no reply
Names a different homepartialpass
Negotiationfail — nothing writtenfail — canned line, silence
Manager decides yes/noblocked, untestable hereblocked, untestable here
Move-out noticefail — nothing writtennot run
Spanishfail — answered in Englishpass
Asks for a humanfail — never disclosed AInot run

Top 5 gaps:

  1. Voice tools (accept, counter-offer, move-out notice) run but write nothing to the record — a resident can give notice by phone and reminders keep firing.
  2. An unknown caller mentioning a renewal causes an infinite hand-off loop between Triage and the renewals specialist until the call dies.
  3. A text from a number not on file gets no reply at all, even though it was delivered.
  4. Clara claims an offer was already emailed when no such email was sent, and a hallucination-guard substitution ("looping in your property manager") wrongly passes as a kept promise.
  5. The manager decision loop can't be tested at the test property — escalation is email-only and the test property has no routable manager inbox.
Goal and definition of done

Goal and definition of done

Western Slope live on renewals by Friday 2026-10-02, and invoiced for them. Done means all of this is true:

  1. Clara builds the renewal list per home, across every Western Slope property, on one board.
  2. Kat inspects that list before anything goes out.
  3. Clara sends the offer text and the reminders herself.
  4. Every resident reply comes back as a Clara draft held for Kat or Bev to approve in Slack — the hold flow we already run.
  5. When Clara needs a decision, she texts Kat like a coworker: one tracked question, one chase, and the answer goes back into the renewal.
  6. Western Slope is invoiced for renewals.
The plan, in order — full table with target days and proof

The plan, in order

#StepOwnerProven whenTarget dayStatus
aPhase 0 bug fixesBuildersEach fix ships with the case that reproduced it, live at every propertyTue night 09-23merged / in review
bEvery renewal text names the home — company name + street + unit; property name is the address at Western SlopeBuildersA harness run shows the home on every outgoing message, none genericWed 09-24building
cList automation: leases ending in the window, per home, on one board across all properties, with month-to-month recorded from AppFolio; leases with no end date must be named, never dropped silentlyBuildersThe board lists every property at once and its rows match AppFolioWed 09-24 – Thu 09-25building
dCompany-level policy + eligibility thresholds + voiceCallEnabled, with property overrideBuildersSet once at the company, overridden at one home, and the nearest value winsThu 09-25queued
d2Wire the AppFolio charge feed into eligibility for Western Slope (feed already answers)BuildersBalance and late-payment counts come from real charge_detail rows, not the clean-balance defaultThu 09-25queued
eText first, no opening call, confirmed through the existing settingBuildersA dry run sends the text and places no callThu 09-25queued
fClara texts Kat: tracked question, one chase, answer relayedBuildersA harness test walks question → chase → answer back into the renewalFri 09-26queued
gResident replies held for human reviewBuildersA reply arrives, Clara's draft waits in Slack, nothing sends until Kat or Bev approvesFri 09-26queued
hWillows dry runs with us as the residentsBuildersOffer, reminder, reply, approval and countersign all walked end to end, no real resident touched. Must include seed cell wlh-19 (lease 15 days out, no offer prepared): the resident gets a text with no rate in it, and the office page reads "offer not prepared". This is the delivered-text proof for #11413 and cannot be skippedMon 09-29queued
iFEDE: flip the Western Slope write lock to liveFedeThe robot writes one note to a Western Slope cardMon 09-29gated
jKat walks the first list on the Wednesday callWestern SlopeKat has inspected the list and says it is rightWed 10-01gated
kFEDE: turn it on for the 43 leases ending in the next 90 days (the 43 tracked occupancies in AppFolio's renewal index)FedeThe first offer text is delivered and loggedWed 10-01gated
lInvoice Western Slope for renewalsFedeThe invoice is sentFri 10-02gated

Lower priority, after go-live:

  1. Email a copy of the offer from the leasing domain, so the resident has a document to forward.
  2. Read the held replies once a week and pick which reply types Clara can answer on her own.
Long-term order

Long-term order

  1. Text, end to end — the plan above.
  2. Human-reviewed replies, then autonomous by reply type as each type earns it.
  3. Email a copy of the offer.
  4. Situs Group onboarding with dynamic 13–16 month terms, so lease ends land in leasing season (the Phase 3 item below).
  5. Branded-line experiment on one cohort.
  6. Counter-offers and market-rent flags.
  7. Spanish, holdover, and the link into turnovers.
What still needs an answer from Western Slope

What still needs an answer from Western Slope

Questions we bring to Western Slope on the first Wednesday call:

  1. Standard increase and term options for the offer template (12 months everywhere, 14 to 18 at Ember Estates?).
  2. Who approves a counter-offer, and within how long.
  3. Month-to-month premium ($150 today) and notice (30 days written) confirmed per template.
  4. Do they want the resident emailed the offer document, texted a link, or both.
  5. What "done" looks like on their side: signed in AppFolio, or a note on the card.

Question for Situs: which months do they want lease ends to land in, and what incentive are they willing to give?

Reference: what the data says

Reference

Research behind the plan, 2026-09-23.

The engine is sound; the plan around it was built for the wrong customer. Temporal is healthy, the workflow has real replay and idempotency discipline, and Camellia-specific code is smaller than feared. What was built for Camellia assumes one building, an on-site team that never answers, and a phone call as the opening move. Western Slope is 70 scattered homes, a two-person team that answers, and residents who let calls go to voicemail. The MVP is mostly configuration and one new view, plus dropping the call.

What the data says (Camellia, June 25 to December 31 cohort, 52 leases)

In plain terms

Text works, calls do not, and the team learns about problems after the resident does.

  • 9 renewed, 19 gave notice, 4 transferred, 15 still deciding, 3 stuck because the offer never got created, 2 never contacted at all.
  • Renewal rate among decided cases: 9 of 32 (28%). Median 16 days from first text to signature.
  • Calls: 29 placed, 2 reached the resident, 0 produced a decision. 16 hit voicemail, 5 hit call-screening robots, 2 reached the wrong person.
  • About 11% of units (roughly 6 of 55) got no contact at all for reasons that were our fault, not the resident's or a case already resolved elsewhere.
  • The "connected" label on calls is wrong 10 times out of 12. Any dashboard built on it is wrong.
  • The text goes out about 1 minute before the call, so the voicemail promises a text they already have.
  • Text reply rate 20%. Two of the nine renewals and the only clean decline were decided in the text thread. Reminder 2 is the highest-yield touch.
  • Email was never used as an outreach channel. The one email exchange was a counter-offer a PM settled by hand.
  • 37 of 41 offers were priced above the unit's own market rent, median +$150. One resident caught it.
  • 4 of 9 renewals AppFolio shows as signed are not marked renewed in PropFlow.
  • Worst single event: a resident was texted a rate and a signing deadline for an offer that had silently failed to create two months earlier.
Channel decision

Channel decision

In plain terms

Lead with text, follow with email, stop calling first, and test a branded line later on a small group if we still want voice.

Options considered:

  1. Text first, email second, no opening call (recommended). Text is the only channel that produced decisions. Email costs nothing and gives the offer a document the resident can forward. The call adds a voicemail, an AI-disclosure risk to screening robots and household members, and no information.
  2. Text only. Cheaper still, but a resident with a dead handset (2 of 31 letters bounced) has no fallback.
  3. Branded phone line (caller ID with the company name). Untested here: the failures were voicemail and screening bots, not "unknown number" rejections. Worth a Phase 2 experiment on one cohort with the spare numbers, measured against the fixed "connected" label. Not an MVP investment.
  4. Keep the current text + call ladder. Rejected by the numbers above.

Voice stays for inbound (residents who call the line) and as an opt-in later step ("want a call? reply CALL").

What we are up against (ranked)

What we are up against (ranked)

  1. Everything is configured per property, no company level. Enrollment toggle, policy row, timezone: 70 times each, two with no set-once mechanism. Add a company tier with nearest-wins (property beats company beats default), mirroring how tour hours and the contact email already work.
  2. They renew through AppFolio's leasing flow with amendments, not the renewals module. Their renewal reports are empty and five lease templates disagree on month-to-month notice (21 vs 30 days). We give them the setup checklist; they adopt the renewals module before we turn on.
  3. The renewals board refuses "All properties". The data loader already supports it; the page does not. Kat and Bev need one queue.
  4. Escalations were built for a team that never answers. Fire-and-forget email, no tracked question, no chase. Western Slope answers, so the "needs your answer" loop becomes the product, not a nice-to-have.
  5. Month-to-month is a product decision, not a sync bug — and the real volume is bigger than it looks. AppFolio has no month-to-month tenant status for us to sync from; our own readers already treat an expired-active lease and a month-to-month one alike; and AppFolio's own expiration report shows 89 of 91 Western Slope rows as month-to-month. About 60 Western Slope leases in our data have an end date in the past and are not marked month-to-month — the open question is how month-to-month residents enter the renewal funnel, not a broken sync. Sized per household rather than per named adult, Western Slope carries 117 lease ends over the next twelve months — only 18 of them in 2026, peaking at 38 in May–June 2027.
  6. Small, sharp, cheap: quiet hours default to Chicago time; two send doors skip the module gate; the escalation email at Western Slope points at an internal placeholder; the renewals specialist on the phone line had no module check; three Temporal patches retire September 29 and 30.
  7. Turning on AppFolio writes is Fede's call, not a Western Slope task. The robot already signs in to Western Slope's AppFolio. Writes are blocked by a read-only lock we set on 2026-09-08 because Western Slope has no test property. Turning it to live is one setting on their account row in the browser agent, Fede's call, no build and nothing for Western Slope to do.

What does NOT break

The workflow engine, saga read-model, eligibility engine, send guard, reply-mailbox chain, PM-contact email chain (already has a company rung), tour-hours company tier, per-property AppFolio account resolution, module entitlement resolver.

Phases

Phases

Phase 0 — Fix what is broken now (this week, in flight, no waiting)

Bug fixes ship live, one PR each, with the reproduced case. Opened tonight:

  • Never text a price when no offer exists; escalate once instead.
  • One renewal per lease per cycle (one lease minted 10 sagas and contacted nobody).
  • Call outcome classified from the transcript, not from "someone made a sound".
  • Mark renewed in PropFlow when AppFolio shows the countersignature.
  • Offer creation that fails becomes a PM task within 24 hours, not a 72-day silence.
  • Escalation tool never tells the resident to call the office themselves.
  • Bounded executed-lease polling (19 workflows loop forever today); non-determinism alert turned on; deploys no longer overlap; eval-matrix noise filtered from renewal alerts.
  • Quiet hours in the property's own timezone; every send door asks the module gate.
  • Legacy list verified against today's code: letter-MMS resend guard, webhook triple-processing race, silent SMS-only default, cancel that reported success without cancelling, 3 AM schedule, dead scaffolding, stale planning docs.

Phase 1 — MVP at Western Slope (next week, dark, then Fede's go)

The smallest thing that reuses everything and can be charged for:

  • Their side (checklist, one page): Adopt the renewals module with one offer template; settle month-to-month premium and notice per template; name the escalation owner (Kat) and a backup (Bev). The robot already signs in to Western Slope's AppFolio; turning writes on from read-only is Fede's call on the account row in the browser agent, not something Western Slope sets up.
  • Our side: company-level policy, enrollment default and timezone (nearest-wins); "All properties" renewals queue; month-to-month recorded from AppFolio; text-first cadence (letter text, reminder at 2 days, reminder at 9 days, a third touch at 30 days) with the offer emailed the same day; no opening call; escalation to Kat by email with a tracked question and one chase after 2 business days; AppFolio countersign closes the loop.
  • Proof before turn-on: the local lab replays a year of Camellia renewals against this code; Willows dry runs with us as residents; then Fede turns it on at Western Slope for the 117 lease ends sized over the next twelve months (18 in 2026, peaking at 38 in May–June 2027).
  • What we deliberately leave out: counter-offer negotiation by Clara, automatic repricing, Spanish, holdover conversion, the call.

Phase 2 — Learn from Western Slope (weeks 2 to 6, driven by the Wednesday 3 PM Mountain call)

Each item starts only when Kat, Bev or Jason names the pain:

  • Counter-offers: Clara asks Kat "approve $X?" and relays the answer (the escalation practice Fede wants).
  • Market-rent drop flags an open offer to the PM before the resident notices.
  • Third-touch and reminder tuning from their reply data.
  • Unreachable-contact tasks (dead handset, full voicemail) to the team.
  • Spanish on the renewal side (exists on leasing).
  • Branded-line experiment on one cohort, measured with the fixed connected label.
  • Holdover and month-to-month conversion with the AppFolio flag.
  • Link to turnovers and "coming soon" listings when notice is given.

Phase 3 — Second customer (Situs Group) and beyond

Situs called renewals "the hugest ROI" and has no process today; their lease's 30-day notice conflicts with AppFolio's auto-email. Same MVP, company-level config, plus whatever Phase 2 proved. Yardi and other systems stay out until AppFolio customers pay.

  1. Dynamic renewal terms (Situs Group ask, two to three weeks out): instead of a fixed 12-month renewal, Clara offers a 13-to-16-month term so the next lease end lands in the busy leasing season (spring) instead of winter, with an incentive attached — a small credit or a rent reduction — to get the resident to take the longer term. The rule is worth it only when the incentive costs less than the expected vacancy cost of a lease ending in winter. Inputs needed: the company's seasonality (which months lease fastest), average days-vacant by month, the incentive budget, and which term options exist per template. Source: Fede/Eileen whiteboard deep-dive, Situs Group, 2026-09-01 — Eileen's wish for "multi-term option menus like Yardi (e.g., 1-month at a punitive rate, 12-month standard; penalize lease-ends landing in Nov–Feb)," #transcripts, 2026-09-01.
Western Slope's AppFolio setup: what to change

Western Slope's AppFolio setup: what to change

This audit reads the deterministic capture taken September 7–8, before Western Slope's September 9 upgrade to AppFolio Plus.

AreaWestern SlopeCamellia
How renewals are doneMostly the leasing flow plus amendments. Renewal index (43 tracked occupancies, 09-08): 27 eligible with no offer, 4 out for signing, 3 countersign pending, 9 completed through the renewal moduleRenewal module driven by PropFlow automation, autonomous since 2026-07-23
Lease templates5 usable, no single default, plus a live "Sample Lease Template"One renewal template, separate from the new-lease template
Month-to-month notice2 of 5 templates say 21 days; 3 say 30. Client-confirmed policy is 30No conflict found
Month-to-month fee$150/month verbatim in 4 of 5 templates; the MTM-only template doesn't restate itNot documented
Standard term12 months; Ember Estates' 14–18 month promo has no template of its own12 months
Renewal reportsPopulated at the roll-up level; the workflow-level Renewal Index is thin — 27 of 43 with no offer draftedNot re-pulled for this audit
AppFolio reminder emailsLikely not firing for most leases, since no offer object exists to remind aboutNot re-verified
Property groups (Plus bulk renewals)Already in use; the bulk-renewal feature itself is unverified since the capture predates Plusn/a, single property
Owner approval per propertyNot found; unverifiedn/a

Must-do before turn-on

  1. Fix both WSPM templates from 21 to 30 days' month-to-month notice — the shorter notice is a legal exposure if it ever reaches a resident.
  2. Consolidate to one canonical renewal template instead of five, so the notice period and fee can't disagree with each other again.
  3. Confirm the $150 month-to-month fee appears in the month-to-month-only template, which is the one that currently omits it.
  4. Create renewal-offer objects for the 34 eligible leases that don't have one (only 9 of 43 do today), because reminders, the Renewal Index and the 90-day outreach all read from that object.
  5. Give the Ember Estates 14–18 month promo a real template, since it is applied by hand today with nothing to check it against.

Nice-to-have

  1. Retire or clearly label the "Sample Lease Template," since it is live and selectable today.
  2. Verify the 89-of-91 month-to-month rate with Jason, to tell real occupancy apart from a data-cleanup backlog.
  3. Use the existing property groups for bulk renewal sends once Plus bulk renewals are confirmed on.
  4. Ask Jason directly about per-property owner-approval requirements, since none are visible in the capture either way.

What we change on our side

  • No single-constant template pattern like Camellia's; Western Slope needs per-property template resolution.
  • The robot already signs in to Western Slope's AppFolio. Writes stay behind the read-only lock we set on 2026-09-08 because Western Slope has no test property; flipping it to live is one setting on their account row in the browser agent, Fede's call.
  • Read the notice period from each template's own value (21 vs. 30), never from one company-wide constant.
  • Never infer "not renewing" from an empty Renewal Index — 27 of the 43 tracked occupancies likely renewed through the leasing flow, not through silence.
In plain terms

Two of Western Slope's five lease templates promise residents 21 days' notice to end a month-to-month lease, while the company's actual policy is 30 — a resident who is held to the template's wording is being told the wrong number. And only 9 of the 43 renewal-eligible homes have an offer on file in AppFolio, so AppFolio's own reminder emails have nothing to remind the other 34 about.

Temporal verdict

Temporal verdict

Only 20 days of history survive because the old namespace was deleted at the September 3 cut. Weak spots are observability, not correctness: the non-determinism alert was a stub that was off (fixed tonight), one polling workflow loops forever with no alarm (fixed tonight), the renewal workflow never resets its history (fine today, a risk at scale), and worker versioning is one setting away from on.

Local lab

Local lab (tonight)

Two rigs, both reusing existing pieces, no CI, no review:

  • Scan-level replay: a year of real Camellia renewals through the daily scanner on an in-memory database with AppFolio mocked, scored per renewal as match, diverged, not-replayable-by-design, or crashed.
  • Workflow-level replay: each real renewal's recorded signals through today's workflow code with time-skipped timers.

Results land in Lab results when the runs finish.

Fable vs Opus: two independent inspections

Fable vs Opus: two independent inspections

Two models ran the same inspection brief independently, each walled off from the other's work. A third pass reconciled every number they disagreed on against fresh, one-shot queries of prod DynamoDB and the ElevenLabs API.

The second run reported itself as Claude Opus 5 with the 1M-token context window.

In plain terms

One model alone would have shipped this plan with either a wrong call count or a wrong "nobody got contacted" headline. Running two independent inspections and reconciling them caught both mistakes before either reached this page.

Reconciled numbers

NumberFable saidOpus saidWhat is trueDefinition used
Camellia case count5291Both exact on their own terms: 91 in-window sagas cover 55 units / 56 peopleA renewal is one lease cycle, not one engine retry — use 52. Opus's 91 counts sagas, and the engine re-mints sagas per lease (unit 416 alone is 10)
No tenant contact2 never started + 3 stuck (narrative)51%46 of 91 sagas is exactly right at saga level. At unit level: 13 of 55 units (24%)Count by unit. Of those 13, 8 had already given notice, 1 transferred, 1 was renewed by hand — the actionable failure is ~6 units (11%)
Offers prepared / prepare failures41 of 52 (79%)24 of 91 (26%) prepare_failedNot contradictory — different questions, all correct on their own terms79% of cycles got an offer (funnel). 20% of units hit a prepare failure at least once. 7% are still stuck today — the honest headline
Calls262929 is the true count of calls placedElevenLabs is the system of record for "does the phone work"; DynamoDB is a partial mirror, good for "what happened to this cohort" (~26)
Renewed9119, verified against AppFolio's own countersign dateThe PMS is the record. 5 of Opus's 11 are stale sagas mis-dated into the window; the other two projections in that report disagree with each other
Western Slope lease-end volume43 in the next 90 days117 over 12 months, 18 in 2026, peak 38 in May–Jun 2027Same rows, same index — the difference is per-person vs. per-householdA renewal decision is made once per household. 117/18/38 is the right sizing; 43 over-counts by counting every named adult

What Opus found that Fable missed

  1. CONFIRMED — the three voice tools that record a renewal decision (renewal_accepted, renewal_declined, change_renewal_term) have never fired in production, across all 40 stored calls.
  2. PARTLY — an authorization stamp structurally blocks them on an outbound call. The chain is real, but the tools are "unstamped at dial, therefore unreliable," not "structurally incapable" as stated — a tenant with an existing conversation row can re-anchor and pass.
  3. PARTLY — no AI disclosure on outbound renewal calls. True that 0 of 29 transcripts disclose, but the composed prompt does carry a reactive disclosure line; what is missing is a proactive one in the outbound opening message.
  4. CONFIRMED — "Denver, CO" is hardcoded into Clara's personality, duplicated across four production prompt files, and already live on Western Slope's leasing side.
  5. CONFIRMED (mechanism) — the lapse digest sends one email per property per working day with no org-level batching, which at Western Slope's building count means dozens of emails into one inbox daily.
  6. CONFIRMED — the ADR-0108 "prepare reconciler" (PR-A5) was named and never built, which is why offer-prepare failures have no automatic recovery.
  7. CONFIRMED — per-household Western Slope sizing (1.94 adults per unit vs. 1.05 at Camellia) means a per-person cadence sends roughly double the messages a per-household one would.

What Fable found that Opus missed

  1. CONFIRMED — the call "connected" label is wrong on at least 82% of the leases carrying it; one writer stamps raw Twilio call status, blind to who answered.
  2. CONFIRMED — the letter text precedes the call by a median of about one minute in 27 of 28 pairs, so the call carries no information the resident does not already have.
  3. CONFIRMED — 37 of 41 offers were priced above the unit's own market rent (re-derived: 44 of 46, median +$150, max +$240).
  4. CONFIRMED — a resident was texted a rate for a renewal offer that was never created in AppFolio (unit 204).
  5. CONFIRMED — one lease minted 10 renewal sagas, eight of them cancelled in a retry loop, and the tenant gave notice.
  6. CONFIRMED — 4 of the 9 real renewals are not marked renewed in PropFlow.
  7. CONFIRMED — three Temporal workflow patches retire September 29 and 30, and nothing is watching that date.
  8. CONFIRMED (and worse than stated) — two outbound send doors, including the renewal letter MMS, skip the compliance module gate entirely; the gate's own code comment admits the hole.
  9. CONFIRMED — quiet hours default to Chicago time for any property missing a timezone, which is a TCPA-hours exposure, not cosmetic.
  10. CONFIRMED (from the customer, not the code) — Western Slope renews through the leasing flow with amendments, not the renewals module, which is why their renewal reports are empty.

Both got wrong

Both reports cite a function, evaluateRenewalReachability, as the existing fix for silent undelivered messages. It does not exist anywhere in the repo. Any plan whose cheap win is "just wire up the function that's already written" is planning against code that was never written.

Neither caught

Nothing reacts to a failed text delivery today — that has to be written, not wired. The AI-disclosure line is missing specifically from the outbound first message, not from the agent generally. And the mailed/MMS letter step is not behind the same compliance-module gate as the other send doors.

Verdict

Both reports agree on the shape of the job: the engine is sound, the phone is the problem, and getting Western Slope live is a configuration job, not an engine rebuild. Where they gave different channel recommendations, the text-first plan is the better-supported one — it is backed by both reports' own evidence once reconciled, including the fact that voice has produced zero decisions and the call adds no information the text hasn't already delivered. Two things are worth borrowing from the Opus run as written: turn voice off with a visible, reversible company setting rather than deleting code, and size the Western Slope rollout off the per-household numbers, not the per-person count.

Lab results

Lab results

The lab exists and runs in about a minute. It found one real bug and one replay artifact, plus one hard limit: there is no year of Camellia renewal history to replay. The oldest renewal record opened 2026-04-30, so the corpus covers under five months.

In plain terms

We replayed every real Camellia renewal through today's code with a year of timers compressed. Nothing crashed, most matched, ten diverged, and the resident-reply half of the workflow was never exercised because the records store what AppFolio did, not what residents said.

Lab B: each renewal through today's workflow (time-skipped)

Corpus: 66 sagas with a lease end between 2025-09 and 2026-12.

lease endsagas
2026-051
2026-062
2026-075
2026-0810
2026-0918
2026-108
2026-1111
2026-1211
saga statecount
MIGRATED_TO_WORKFLOW19
NTV_RECEIVED14
CANCELLED14
PREPARE_FAILED7
TRANSFERRED6
EXECUTED4
OFFER_PREPARED2

Scorecard: MATCH 30, DIVERGED 10, NOT-REPLAYABLE-BY-DESIGN 26 (16 still open, 6 pre-cutover, 4 transfers), CRASHED 0. Two runs on the same corpus produced identical verdicts. The mutation control (plant a wrong recorded outcome, require the scorecard to flip it) passed. Runtime: 40–68 s end to end for all 66 sagas.

One product bug and one replay artifact, both recorded as observations. The bug is queued as a live fix under the bug-fix rule (no gate, ships to all properties); the replay artifact needs no fix.

  1. Replay artifact, not a live bug (verified 2026-09-23 night, PR #11376 closed). The five sagas escalated under the old hand-set 10-minute timeout and were superseded afterwards; replaying their recorded supersede signals into today's 42-minute wait produces cancelled, but today's code cannot reach that state: PREPARE_FAILED has one production writer and it runs only after the workflow has already recorded its outcome. Lesson for the lab: recorded signals replayed across a timer change can mislead; check reachability against today's code before filing. Five saga ids hit this: saga_29a0481f-7e67…, saga_8f17a1af-a097…, saga_baf2ac4b-f224…, saga_03d19ece-7319…, saga_c77cd00b-1233….
  2. Real bug, queued as a live fix (PR #11378). When a resident never replies to any reminder, the workflow writes its final phase and then stops — it never records an outcome at all. Three saga ids: saga_b21780ac-b954…, saga_8f533020-5d20…, saga_239995fa-fc9b….

Temporal-specific findings: no non-determinism, no patch mismatch, zero crashes across all 66 sagas. Timer matrix confirmed by observation: prepare wait 42 minutes, reminders at +48 hours and +7 days, expiry nudges at lease end minus 30 and minus 15 days. Two lab-side traps were found and fixed, not product bugs: the test clock's sleep() can race the workflow's first task and skip past its own timeout, and calling result() before all signals are delivered unlocks time-skipping early and delivers signals "after" the workflow already closed.

Coverage gap: across a year of real renewals, only two of the workflow's signals ever fired — pmsChanged and pmsFailed. tenantIntent, deliveryStatus, pmAction, pmStatusOverride, and every Update, never fired once. The entire resident-reply half of the workflow is unexercised by real data.

Lab A: the daily scan over 487 simulated days

Corpus: 120 of 165 prod renewal sagas are Camellia's, with 513 activity-log events, 103 leases, and 115 AppFolio renewal_summary rows joined over the HTTP API — no browser robot involved.

171 renewals scored in 10.9 seconds: MATCH 4, DIVERGED 2, NOT-REPLAYABLE 165, CRASHED 0. Invariants: one-cycle-per-tenancy PASS (33 cycles opened, none twice); inside-the-property's-window PASS; page-dedupe and quiet-hours were not run because both live inside the Temporal workflow, not the daily scan this lab replays. Both mutation controls came back green.

100 of the 165 unreplayable rows share one root cause: the corpus is a snapshot of today's book, not a time series. Lease ends have since rolled forward, and residents who have since moved out are archived out of the book the walk can see. Separately, 20 sagas sit in MIGRATED_TO_WORKFLOW and are ungradable at the saga level — their outcome lives only in Temporal history.

One finding worth keeping: 4 of the 8 PREPARE_FAILED residents at Camellia have no email on file, and today's code holds them at the signer-email gate rather than sending them into AppFolio prepare. That is today's code being stricter than the code that ran in production, in the direction that prevents the failure production actually hit — not a bug.

How to run it

Both labs are unpushed, no-PR worktrees, by design tonight.

cd /Users/federicochapa/.claude/propflowai-lab-renewal-replay-corpus
export LAB=/private/tmp/claude-501/-Users-federicochapa-agents-004/268439c3-a006-4372-897c-3735a55d3990/scratchpad/lab

# 1) capture (READ-ONLY against prod DDB + the AppFolio HTTP API)
mkdir -p $LAB/corpus
npx tsx scripts/renewal-harness/replay/capture-camellia.ts --out $LAB/corpus
#   optional: --from 2025-09-01 --to 2026-12-31 --no-appfolio

# 2) scrub — the driver reads ONLY the twin this writes
npx tsx scripts/renewal-harness/replay/scrub-corpus.ts \
  --in $LAB/corpus --out $LAB/corpus/scrubbed

# 3) replay + scorecard
RENEWAL_CORPUS_DIR=$LAB/corpus/scrubbed RENEWAL_CORPUS_OUT=$LAB \
  npx vitest run --config scripts/renewal-harness/replay/corpus/vitest.config.ts
#   knobs: RENEWAL_CORPUS_EPOCH (default 2025-09-01T16:00:00.000Z — 09:00 America/Denver)
#          RENEWAL_CORPUS_DAYS  (default 487 = 2025-09-01 .. 2026-12-31 inclusive)
#          RENEWAL_CORPUS_STRIDE(default 1; scan every Nth simulated day)

# 4) the two mutation controls
npx tsx scripts/renewal-harness/replay/corpus/controls/window-regression.control.ts
npx tsx scripts/renewal-harness/replay/corpus/controls/prod-table-guard.control.ts

# 5) the synthetic CI path, unchanged, to prove nothing regressed
npm run test:harness:renewal-replay

Worktree /Users/federicochapa/.claude/propflowai-lab-renewal-replay-corpus, branch lab/renewal-replay-corpus, three commits, unpushed.

# 1. the worktree (already created; recreate with)
cd /Users/federicochapa/.claude/propflowai
bash scripts/worktree-setup.sh lab/renewal-workflow-replay

cd /Users/federicochapa/.claude/propflowai-lab-renewal-workflow-replay

# 2. @temporalio/testing is NOT a repo dependency — install it
npm install --no-save --no-audit --no-fund @temporalio/testing@1.19.0

# 3. run. The corpus is cached under scratchpad/lab/corpus-b, so only the
#    FIRST run touches DynamoDB (read-only Query, never a write).
AWS_REGION=us-east-1 npx tsx testing-harness/scenarios/production-replay-timeskip.ts

#    flags
#      --recapture   re-pull the corpus from prod DDB
#      --limit N     replay only the first N sagas
#      --mutate      run the mutation control (plants a wrong recorded
#                    outcome on one MATCH row; must report DIVERGED)

Worktree /Users/federicochapa/.claude/propflowai-lab-renewal-workflow-replay, branch lab/renewal-workflow-replay, unpushed.

What the lab still cannot answer

  1. What residents actually said — this needs a tenant simulator fed from real conversation threads, not the saga log.
  2. A time-rewound book, so the scan sees each tenancy's lease end as it stood on the day the saga opened, not as it stands today.
  3. AppFolio balance fixtures, so a resident who owed rent can be reproduced.
  4. A second control over the verdict logic itself, not just the window invariant and the sandbox guard.
  5. Atlas registration and a CI job — deliberately skipped for a local lab; done once Fede wants this kept running.
Lab C: Western Slope, end to end on their real data

Lab C: Western Slope, end to end on their real data (2026-09-23 night)

The scattered-home shape is not the problem. Homes resolve 183 of 183, tenants 178 of 183, households never double-minted, zero crashes. What stops Clara is configuration that exists only per property.

Read-only lab against production: 70 Western Slope properties, 234 units, 183 live tenancies, four scans (today, +30, +60, +90 days). No write reached AppFolio, Temporal Cloud, DynamoDB or Twilio — credentials were removed from the environment and a fake table name was used.

  1. No renewal policy at any of the 70 homes — every in-window lease blocks on "missing a renewal policy with term options," so the scan opens 0 renewals across all four scans. Plant one policy (the only change made) and 41 open at 25 homes — that swing from 0 to 41 is also the control: a verdict that cannot move is not a measurement.
  2. The policy lives per property, not per company. Setting it for Western Slope means 70 writes, once per home and again every time a home is added, against 1 write for Camellia. There is no company layer to write it once.
  3. Month-to-month goes nowhere. 36 leases that already ended (one nearly ten years ago) get skipped every scan as holdover "not_armed" — no conversion, no text, no PM notice. 15 more have no lease end on the occupancy row at all and are dropped silently, with no log line naming why. There is no month-to-month flag anywhere in the data model; the engine only infers holdover from a past end date.
  4. The first text names the wrong thing. Rendered through the production template, the message says "it's Clara from a single-family home's own street address" and never says Western Slope anywhere — the property name at Western Slope is the street address, and the template treats that field as the community brand. In a four-unit building the unit is never named. One pricing edge: a home whose market rent already sits below its current rent still gets the 5% cap applied, proposing a renewal rate above market.
  5. Eligibility is structurally empty — zero persisted decisions and zero policy thresholds for the whole org — but not because the data is missing: the AppFolio charge_detail feed answered over the HTTP API for all three sampled properties (141 / 24 / 31 rows), with exactly the fields the extractor needs. Wiring it in is a build step, not a data gap.

The text-only switch: renewalPolicy.voiceCallEnabled: false, a per-property field (not an env flag, not an arm). SMS always sends; voice is added unless the property's policy explicitly sets this to false. Default (the field absent, true of every Western Slope property today) means voice is ON.

Worktree /Users/federicochapa/.claude/propflowai-lab-renewal-replay-corpus, branch lab/renewal-replay-corpus, commit 4f33213ed4, unpushed.

cd /Users/federicochapa/.claude/propflowai-lab-renewal-replay-corpus
export LAB=/private/tmp/claude-501/-Users-federicochapa-agents-004/268439c3-a006-4372-897c-3735a55d3990/scratchpad/lab/ws

# 1) capture (READ-ONLY: prod DDB + the AppFolio HTTP Reports API)
npx tsx scripts/renewal-harness/replay/capture-ws.ts --out $LAB/corpus
#    supplement (eligibility rows, org-attached AppFolio credential,
#    renewal_summary, a charge_detail sample): $LAB/capture2.ts

# 2) scrub — the driver reads ONLY the twin this writes
npx tsx scripts/renewal-harness/replay/scrub-ws.ts --in $LAB/corpus --out $LAB/corpus/scrubbed

# 3a) run A — production's configuration, unchanged
RENEWAL_CORPUS_DIR=$LAB/corpus/scrubbed RENEWAL_CORPUS_OUT=$LAB/runs/baseline \
  npx vitest run --config scripts/renewal-harness/replay/corpus/vitest.ws.config.ts

# 3b) run B — the ONE thing added: a renewal policy (12-month term, $150 MTM premium)
WS_LAB_TERM_OPTIONS=1 RENEWAL_CORPUS_DIR=$LAB/corpus/scrubbed \
  RENEWAL_CORPUS_OUT=$LAB/runs/withterms \
  npx vitest run --config scripts/renewal-harness/replay/corpus/vitest.ws.config.ts

# 4) the first text, rendered through the production template
npx tsx $LAB/render-sms.ts
For implementation agents (detailed source reports)

For implementation agents (detailed)

The full source reports behind this page, converted from Markdown, one collapsed block per report. Resident full names are scrubbed to first name + unit and resident phone numbers/emails are removed; team member names (Kat, Bev, Jason, Eileen, Sean, Gera, Fede) are left as written.

Code-architecture deep inspection

Source: scratchpad/report-code-arch.md

Renewals: code-architecture deep inspection — what we are up against for Western Slope

Read-only inspection, 2026-09-23. Repo /Users/federicochapa/.claude/propflowai at e05e8826ad (git fetch origin main run first). Nothing edited, nothing written to production, no polling loops.

Labels: CERTAIN = read directly at the cited file:line. LIKELY = inferred from code plus a dated doc. Doc-sourced history carries its date, because several statuses on the 2026-09-03/04 renewals page have since moved.

Frame. Renewals have run live at exactly one property: Camellia (JP&Co, jpco.appfolio.com, PropFlow property 1773625953462), one building, an on-site team that does not engage. Next: Western Slope Property Management (westernslopepm.appfolio.com, org org_c42d9150-b9ad-46f8-8f5f-b0fe56bacf38), ~70 mostly single-family properties under one org, one CENTRALIZED leasing team (Kat, Bev; owner Jason), AppFolio read-only today. WS stored modules: leasing.prospects on; leasing.renewals, maintenance, collections off (go-live tracker, DDB PROFILE row, read 2026-09-13 and 2026-09-23).

Contents

  1. Component inventory
  2. Camellia-specific assumptions baked in
  3. What breaks for a centralized multi-property org (ranked S/M/L)
  4. Skeletons and technical debt
  5. Where it is working well
  6. Temporal usage specifically
  7. Ranked answer to "what are we up against"

1. Component inventory

1.1 The spine in one paragraph

A daily Temporal Schedule runs the auto-start scanner (src/lib/domain/leasing/renewal-auto-start.ts, 2,579 lines), which walks every property on the platform carrying autonomousRenewalEnabled or autonomousHoldoverEnabled, finds leases inside that property's own window, and starts one renewalWorkflow per lease. The workflow owns the cadence (letter MMS -> SMS -> voice -> reminder ladder -> expiry nudges -> countersign wait) and is the sole writer of renewal lifecycle state into the DynamoDB RenewalSaga read-model (ADR-0047). Offer preparation in AppFolio happens outside Temporal: an SQS job goes to the agent-runtime Lambda, which POSTs the external appfolio-browser-agent Vercel app (/api/run-renewal, Browserbase) and PATCHes back to /api/leases/[leaseId]/renewal, which advances the saga. Four Vercel crons are alert/remediation backstops. Tenant intent arrives as Temporal signals from Clara's voice/SMS tools and from the AppFolio rent-roll poller.

1.2 Temporal components

Thing File
renewalWorkflow (3,743 lines) src/lib/temporal/workflows/renewal-workflow.ts:1035 (type string 'renewalWorkflow' :464)
renewalAutoStartScanWorkflow src/lib/temporal/workflows/renewal-auto-start-workflow.ts:36
cohortWalkerWorkflow (eligibility engine; evaluate+persist only, never starts a renewal) workflows/cohort-walker-workflow.ts
expiry-nudge schedule helper (pure) workflows/expiry-nudge-schedule.ts:46
shared retry policies workflows/retry-policies.ts
renewal activities (4,003 lines) activities/renewal.ts
auto-start activity wrapper activities/renewal-auto-start.ts:25
cohort walker activity activities/renewal-cohort-walker.ts:58
scope pin / checkpoint activities (6 sites) activities/renewal-scope.ts
quiet hours activities/quiet-hours.ts
PM-action-reminder bridge (countersign nags) activities/pm-action-reminder-renewal-bridge.ts, workflows/pm-action-reminder-workflow.ts
reminder backstop activities/pm-action-reminder-backstop.ts, workflows/pm-action-reminder-backstop-workflow.ts
worker entrypoints workers/renewal-worker.ts, services/renewal-worker/index.ts
worker infra (ECS task def, IAM) infra/temporal-worker/
client (start/signal/schedules) src/lib/temporal/client.ts
arm store (durable CONFIG/RENEWAL_ARMS) src/lib/temporal/arm-store.ts
gates autonomous-gate.ts, autonomous-gate-core.ts, holdover-gate.ts, turnover-gate.ts
receipts src/lib/temporal/renewal-receipts.ts

Full Temporal detail (ids, signals, timers, retries, patches, deployment) in section 6.

1.3 API routes

Route Methods What it does
src/app/api/leases/[leaseId]/renewal/route.ts GET, PATCH The browser-robot callback. pmsSyncStatus='synced' -> saga OPENED->OFFER_PREPARED + fires the outbound Clara voice call; 'failed' -> PREPARE_FAILED. Deduped via recordEvent(eventId).
src/app/api/leasing/renewals/route.ts GET Board/list feed via loadRenewals.
.../renewals/[leaseId]/route.ts GET Renewal detail (same loader the server component uses).
.../[leaseId]/letter-mms-retry/route.ts POST Manual retry of a failed renewal-letter MMS (executeRetryLetterUpdate).
.../approve/route.ts POST PM "Offer renewal" / "Convert to MTM": eligibility + policy checks, start/resume the workflow, enqueue the prepare job (triggerRenewalSync at :763).
.../decision/route.ts POST ADR-0100 disposition-neutral alias — re-exports approve's POST verbatim.
.../decisions/route.ts GET The eligibility engine's AUTO_RENEW / HITL_REVIEW verdict per tenant.
.../external-resolution/route.ts POST Human confirm/dismiss lane for PMS-side events the reconciler would not auto-apply.
.../outreach/route.ts POST Start/continue outreach; same eligibility spine; enqueues prepare.
.../policy/[propertyId]/route.ts GET Latest RenewalPolicy (v6 thresholds + autonomousRenewalEnabled) for the Renewal Strategy editor.
.../reset/route.ts POST Destructive admin/demo reset back to "Ready to send".
src/app/api/renewal-change-reviews/route.ts GET The change-review queue (term/rent edits).
.../[id]/decision/route.ts POST Executes an approved change against AppFolio (executeRenewalChange); extended Vercel function duration because the browser runner is slow. requireRole(request,'conversations','edit') at :102.
src/app/api/admin/arms/route.ts GET, PUT Arms catalog for /admin/dev/access-inspector. The durable CONFIG/RENEWAL_ARMS row and env arms are READ-ONLY here; only per-property autonomous*Enabled and global kill switches are writable.

1.4 Crons (vercel.json)

Path Schedule Purpose
/api/cron/renewal-stuck-saga-sweep /15 * Alert-only. Open saga stuck in PREPARE_FAILED, or non-terminal and stale >24h. Never mutates.
/api/cron/renewal-external-reconcile 0 ADR-0091. Diffs AppFolio truth vs saga state; auto-closes when guards pass, else routes to the human confirm lane.
/api/cron/renewal-workflow-health 0 Alert-only. For sagas whose state implies a live workflow, describe() Temporal and alert if it is gone.
/api/cron/renewal-prepare-retry /30 * ALWAYS-ARMED remediation. Retries the narrow PREPARE_FAILED subset by minting a fresh cycle; durable per-lease attempt cap.

Domain logic: src/lib/domain/renewals/{stuck-saga-sweep,workflow-health-sweep,external-reconcile,external-reconcile-sweep,external-apply,retry-failed-prepare,prepare-retry-budget}.ts. Against the repo rule at CLAUDE.md:87 ("a new cron added to compensate for a missing timer is the wrong direction"), three of four are alert-only, which is the sanctioned shape; the prepare-retry cron is remediation that arguably belongs inside the workflow (LIKELY).

1.5 Lambdas / SQS / the browser robot

  • Live offer-preparation path (CERTAIN). triggerRenewalSync(leaseId, propertyId, payload)

(src/lib/integrations/sqs/agent-jobs.ts:235) publishes a FIFO job to AGENT_JOBS_QUEUE_URL (agent-jobs.ts:22). Callers: renewals/approve/route.ts:763, renewals/outreach/route.ts, renewal-auto-start.ts:1966 (skipped only when workflowOwnsDispatch). The agent-runtime Lambda (lambda/agent-runtime/src/handlers/renewal-l4.ts) POSTs APPFOLIO_BROWSER_AGENT_URL + /api/run-renewal (external repo, Browserbase), which drives AppFolio's internal API and returns renewalOfferId; the Lambda PATCHes /api/leases/<id>/renewal.

  • Which AppFolio database is resolved per property, fail-closed: resolveAppfolioAccountFromProperty

(src/lib/integrations/appfolio/resolve-account.ts) reads Property.pmsAppfolioAccount (types.ts:3827) and throws AppfolioAccountUnresolvedError when absent. It rides every runner call as the x-account-id header (l4-core.ts:966). The code names only two live databases: jpco and situsgroup (l4-core.ts:3467, 3703; test l4-account-header.test.ts:94,107).

  • PMS write capability gate: Property.pmsSource is "the sole gate for any push-to-PMS code path

(triggerPmsSync, triggerRenewalSync)" — admin/dev/pms-inspector/entity-schemas.ts:2826.

  • Read-back / truth: the rent-roll poller syncPropertyRenewals (src/lib/domain/pms/writers/renewal.ts)

handles three AppFolio row states — renewed, tenant_signed, prepared — emitting saga events (never competing writes), each idempotent on pmsRef.

  • Decommissioned: lambda/renewal-nudge — only dist/ remains; EventBridge rule

propflow-renewal-nudge-schedule is DISABLED (.claude/decommission-snapshots/renewal-nudge-20260430T030542Z/eventbridge-rule.json). The old propflow-renewal-outreach Lambda was deleted 2026-05-01 (.claude/decommission-snapshots/renewal-outreach-20260501T194232Z/README.md). Also appfolio-sync-20260430T184716Z/{rule,targets}-renewals.json.

1.6 DynamoDB

Entities (src/lib/data/dynamo/helpers.ts): RenewalSaga (:1009), RenewalSagaOpenClaim (:1010), RenewalView (:1011), RenewalPolicy (:1012), RenewalEligibilityDecision (:1013), RenewalChangeReview (:819), RenewalArmState (:838), RENEWAL_SAGA_PERSON_PK='RenewalSaga' (:796).

Key shapes (src/lib/data/dynamo/renewal-saga.ts:6-10):

  • RenewalSaga: PK=RSAGA#{sagaId}, SK=META; GSI4 renewal-saga-person-index, GSI4PK='RenewalSaga',

GSI4SK={personId}#{openedAt}. Re-keyed off the legacy tenantId onto canonical personId (ADR-0035 D2); lookups go through getSagasByPerson.

  • Claim sentinel: SAGACLAIM#<personId>#<leaseId>#<state>#<pmsRef> / SK=META.
  • RenewalPolicy: PK=PROP#{propertyId}, SK=RENEWAL_POLICY#{version} (dynamo/renewal-policy.ts:10) —

property only, no org component.

  • RenewalArmState: PK=CONFIG, SK=RENEWAL_ARMS, single upsert-only row (types.ts:16059).

RenewalSagaState (verbatim): OPENED, OFFER_PREPARED, PREPARE_FAILED, OUTREACH_ACTIVE, INTENT_TO_RENEW, INTENT_TO_VACATE, HUMAN_REVIEW_PENDING, EXECUTED, NTV_RECEIVED, TRANSFERRED, CANCELLED, HOLDOVER, MIGRATED_TO_WORKFLOW. Terminal set: `EXECUTED, NTV_RECEIVED, TRANSFERRED, HOLDOVER, MIGRATED_TO_WORKFLOW,

CANCELLED. closedReason: 'executed' | 'ntv_received' | 'transferred' | 'holdover' | 'cancelled' |

'resolved_externally' | null`.

Saga fields: id, personId, leaseId, propertyId, state, pmsRef (AppFolio occupancyId), policySnapshot (frozen at open), workflowId, closedAt, closedReason, transferredToUnitNumber?, holdoverOfficeNotifiedAt?, tenantSignedNotifiedPmsRef, last-error.

Storage libs: src/lib/data/renewal-saga/{writer,find-or-mint,workflow-id,bucket-by-person,daily-metrics,negotiating-marker,preparing-timestamp,in-memory-storage}.ts; src/lib/data/dynamo/{renewal-saga,renewal-policy,renewal-eligibility-decision,renewal-projection,renewal-external-events,renewal-change-review}.ts; src/lib/data/{renewal-view,renewal-state-sources,renewal-conv-fallback,renewal-for-tenant}.ts.

1.7 UI

  • src/app/(workspace)/(operations)/leasing/renewals/(list)/page.tsx — the renewals board (graduated from

/leasing/renewals/board, which now redirects: board/page.tsx:13).

  • .../renewals/[leaseId]/page.tsx + RenewalDetailClient.tsx (~2,200 lines) + _components/RenewalDetailGhost.tsx.
  • .../renewals/layout.tsx:12 — requireModuleForSection('/leasing/renewals').
  • .../renewals/board/_components/{BoardV2,PropertyScopePrompt,data,map-renewal,board-scope-gate,live,board-shared}.
  • src/components/feature/dashboard/leasing-renewals/{RenewalsSection,LeasingSection,LeasingRenewalsRow,WeeklySnapshotCard,...}.tsx.
  • src/components/domain/leasing/RenewalsPolicyCard.tsx, src/components/domain/properties/RenewalPolicyEditor.tsx.
  • Admin: admin/dev/renewals-cohort/, admin/dev/access-inspector/_components/ArmsTab.tsx.

1.8 Outreach channels

src/lib/domain/leasing/renewal-orchestration/: send-renewal-email.ts, send-renewal-letter-mms.ts, fetch-renewal-letter-pdf.ts, letter-mms-outcome.ts, letter-mms-status.ts, recap-sms.ts, sms-cascade.ts, initiate-renewal-call.ts, renewal-call-context.ts, record-outreach-conversation.ts, renewal-outreach-hold.ts, renewal-send-guard.ts, channel-outcome.ts, append-pm-notify-to-renewal-conversation.ts, append-reminder-to-conversation.ts, fetch-executed-lease-pdf.ts.

Cadence as the code states it: the letter MMS is Phase 1.5 and fires regardless; renewalPolicy.voiceCallEnabled (default true) decides whether an outbound Clara call fires on offer-prepared; reminder ladder D+1 / D+7 (workflow defaults reminderStep1 = '24 hours', reminderStep2 = '7 days'); expiry nudges at lease-end -30d and -15d; sms-cascade.ts:8 — there is no email cascade any more (retired 2026-05-17), AppFolio's resident portal owns renewal email on that leg.

1.9 Voice agent, tools, prompts

Tools (agents/clara/lib/agent/tools/renewal/schemas.ts:27-125, handlers handlers.ts): renewal_accepted, renewal_declined, renewal_escalate (with counter_offer / counter_offer_terms -> board card "Negotiating terms"), send_portal_link_sms, send_offer_sms, change_renewal_term. Prompt: agents/clara/lib/agent/tools/renewal/prompt.ts. ElevenLabs: config/elevenlabs-agents.json — "Clara — Renewal" (:145), "Clara — Renewal (Outbound)" (:179). Evals: evals/prompts/renewal-{scoping,voice,sms}.ts; evals/gauntlet/run-renewals.ts + golden-dataset-renewals.json; evals/promptfooconfig-renewal-scoping.yaml; gate threshold renewals: 95 (evals/eval-gates.generated.json:45).

1.10 Reports, metrics, reminders, countersign

src/lib/domain/leasing/{renewal-rate.ts, renewal-lapse-digest.ts, renewal-lapse-digest-window.ts, renewal-receipt-ttl.ts, alert-engineering-lapsed-renewal.ts}, compute-light-renewal-rate; src/lib/data/renewal-saga/daily-metrics.ts. Reminders/countersign: pm-action-reminder-backstop.ts, resolve-pm-action-reminder-config.ts, renewal-reminder-sms.ts, pm-action-reminders/{pm-action-reminder-tick-cap,pm-action-link,pm-action-kinds,pm-action-reminder-copy,pm-action-link-appfolio}.ts; countersign detection src/lib/integrations/appfolio/{awaiting-countersign-probe.ts, email-ingestion/handle-appfolio-lease-countersign.ts, email-ingestion/parse-appfolio-lease-countersign.ts}; lambda/appfolio-sync/lease-countersign-signal.ts.

1.11 The RENEWAL_ARMS config

Durable row PK=CONFIG / SK=RENEWAL_ARMS. Written ONLY by scripts/set-renewal-arms.ts (a code ship cannot write it). Read through arm-store.ts with a cache TTL, warmed at worker boot (services/renewal-worker/index.ts:234, workers/renewal-worker.ts:71). Defaults DEFAULT_RENEWAL_ARM_STATE (types.ts:16218).

Field Gates Default Scope
autoStart daily scanner master switch false global (env fallback retired 2026-07-07, arm-store.ts:159)
autonomousSending any tenant-facing renewal send false global AND per-property Property.autonomousRenewalEnabled
holdoverConversion MTM billing mutation + the D5 copy promising it false global AND per-property Property.autonomousHoldoverEnabled
turnoverSending turnover sends undefined -> env fallback global AND autonomousTurnoverEnabled
autoStartExcludedPersonIds scanner + poller must skip these Person ids [] global, per-person
workflowOwnedDispatch {enabled, propertyAllowlist} ADR-0108 D1 — moves the PMS-prepare dispatch inside the workflow OFF, opt-in allowlist (types.ts:16087-16093) per-property
stableWorkflowIdentity {enabled?, rollbackPropertyIds?} ADR-0108 D2 — stable per-lease-cycle workflow id ON by default for every property since 2026-09-03 (opt-OUT) (arm-store.ts:259-268, types.ts:16109-16137) global default + per-property opt-out
collectionsHalt emergency brake, reversed polarity (true=halt) unwritten = lane runs fleet-wide
newLeasePipelineSending new-lease pipeline sends — global

Inert leftovers: --holdover-in-workflow and --walker-spawn (plus their allow/disallow pairs) were removed 2026-09-03 under "one starter, one owner"; scripts/set-renewal-arms.ts:31-39 notes the live row "may still carry these fields from before the retirement — they are inert." Dead data in a production config row.

1.12 Env flags in renewal code (Fede's rule: none should exist — each is a finding)

Real behaviour gates, all legacy fallbacks mid-migration onto the DDB row:

  • RENEWAL_AUTONOMOUS_SENDING — autonomous-gate.ts:42, autonomous-gate-core.ts:108-111.
  • RENEWAL_HOLDOVER_CONVERSION — holdover-gate.ts:23,36,48.
  • TURNOVER_AUTONOMOUS_SENDING — turnover-gate.ts:13,39,56 (its header records the env var being wiped 4x in 72h

by ECS task-def re-renders — the reason the durable row exists).

  • EMAIL_NTV_FIRM_ROUTING_ENABLED — Vercel-side, gate home src/lib/domain/leasing/ntv-gate.ts; disarmed by

default; gates firm NTV routing AND the AppFolio move-out draft.

  • TEMPORAL_NONDETERMINISM_METRICS_ALERT — nondeterminism-metrics-drain.ts; OFF; wiring not landed (ADR-0052 WS4).

Config/infra, not behaviour gates (listed for completeness): AGENT_JOBS_QUEUE_URL, APPFOLIO_BROWSER_AGENT_URL, ELEVENLABS_API_KEY / ELEVENLABS_RENEWAL_AGENT_ID (initiate-renewal-call.ts:55-56), RENEWAL_ARM_CACHE_TTL_MS (arm-store.ts:58), RENEWAL_TASK_QUEUE_OVERRIDE (client.ts:135 — test harness only, never prod), TEMPORAL_USE_VERSIONING (workers/renewal-worker.ts:91), Sentry/build metadata. Canonical inventory: docs/runbooks/renewal-orchestration.md.

1.13 Settings

Property.autonomousRenewalEnabled (types.ts:3996); Property.autonomousHoldoverEnabled; Property.capabilityStage.renewals (types.ts:3630, DECLARED BUT NOT WIRED — see section 4); Property.renewalPolicy {rentStrategy, maxIncreasePercent, maxIncreaseAmount, termOptions, mtmPremium, delinquencyThresholdMonths, voiceCallEnabled} (types.ts:4825, the legacy blob); versioned RenewalPolicy.thresholds {maxBalanceMonths, maxLatePayments, lateGraceDays} (the v6 row); PropertyLeasingSettings.renewalContactEmail (types.ts:15697), renewalContactPhone (:15700), renewalExecutionCcEmails; renewalAutoStartDaysBeforeLeaseEnd default 90 (settings-resolver.ts:56,80,192-195); Property.jurisdictionStateCode (types.ts:3988); Organization.escalationOwnerEmail; module entitlement leasing.renewals -> LeasingRenewalsEntitlement (src/lib/domain/modules/resolve-module-entitlement.ts:84).


2. Camellia-specific assumptions baked in

2.1 Hardcoded ids and addresses

  • 1773625953462 appears in ~120 files — essentially all eval fixtures, replay corpora, canary/Willows harness

scripts and test data. CERTAIN, not a production behaviour risk.

  • The one load-bearing production hardcode is a denylist: scripts/lib/refused-property-ids.ts:37-40 pins

Camellia's id (both AppFolio spellings) so the renewal-policy-facts migration script refuses to touch it. Comment :20-24: "It is the live production property with real residents whose renewals are in flight... Enrolling it is a decision with a human in the room." CERTAIN. Consequence: WS properties would onboard onto the NEW versioned facts path while Camellia stays on the legacy blob — two customers, two policy-resolution code paths. (src/__tests__/renewal-policy-single-home.drift.test.ts header cites 15 non-Camellia live properties enrolled.)

  • No hardcoded customer emails in live renewal source. The only non-test hit is an illustrative comment,

pm-contact-email.ts:10 ("e.g. Camellia = camelliaapts@jp-co.com"). CERTAIN.

  • src/lib/platform/scrapers/camellia.ts:8 carries the property id but is a marketing-website content scraper, not

a renewal path. CERTAIN.

2.2 The policy numbers ("25-day / 6-late / 2.0-balance")

  • Code defaults: DEFAULT_RENEWAL_POLICY_THRESHOLDS = { maxBalanceMonths: 2.0, maxLatePayments: 2, lateGraceDays: 10 }

— src/lib/domain/leasing/eligibility/engine.ts:74-77; DEFAULT_LATE_GRACE_DAYS = 10 at :51. CERTAIN.

  • The 25-day grace and the 6-late ceiling are Camellia's per-property override, not code constants.

engine.ts:38-42: "the confirmed target — a 25-day grace + a widened maxLatePayments ceiling of 6 — moved the property from 59.3% to 92.0% AUTO_RENEW." They live in Camellia's versioned RenewalPolicy.thresholds row. CERTAIN. There is no string "policy v2" in the tree; the internal name is v6 (RenewalPolicyV6Snapshot).

  • Statute floor: only one jurisdiction module exists — eligibility/jurisdictions/co.ts

(CO_STATUTE_VERSION='CO-HB-24-1098-v2024-04-15', CO_CITATION='CO HB 24-1098 §38-12-1305(2)(a)(II)'), applied unconditionally; Property.jurisdictionStateCode is read by COLLECTIONS, not by the renewal eligibility engine. CERTAIN. WS is also Colorado, so this is not a WS blocker — it is a blocker for the first non-CO customer.

  • The same fact is stored twice and four readers disagree on the default. renewal-policy/facts.ts:20-47 states

it: knowledge.renewalPolicy.delinquencyThresholdMonths and RenewalPolicy.thresholds.maxBalanceMonths are the same fact, written to both by RenewalPolicyEditor, and an absent value defaults to ?? 1 in RenewalsPolicyCard.tsx, ?? 2 in PropertyDetailClient.tsx, 2.0 in eligibility/engine.ts, and ?? 1 in statuses/renewals.ts's dormant deriveRenewalStatus. The file's own words: "one unmigrated property already tells a PM two different stories about its own delinquency bar on two different screens." CERTAIN.

2.3 Single-property and on-site-team assumptions

  • The board refuses "All Properties." renewals/(list)/page.tsx + board/_components/board-scope-gate.ts:33-39:

with no ?propertyId= and no single-property workspace scope, the page renders PropertyScopePrompt — "Cohort math is per-property — there's no meaningful cross-property aggregation to fall back to, so null means render the pick-a-property prompt, not guess." CERTAIN. The data layer is NOT the limit: getRenewals(propertyId?) (src/lib/data/store.ts:3987-3991) builds propertyIdsInScope from the tenant set when the id is undefined.

  • Quiet hours default to Camellia's timezone. src/lib/temporal/activities/quiet-hours.ts:26-32 hardcodes

'America/Chicago' when Property.timezone is unset, with the comment "matches Camellia (the only live customer at time of writing)." Called from renewal-auto-start.ts:2314. No org-level timezone tier. CERTAIN.

  • Escalation SMS is property-only with no fallback. renewal-notify.ts:15,115,123 — "phone:

PropertyLeasingSettings.renewalContactPhone only (no fallback — absent means email-only)." CERTAIN.

  • renewalAutoStartDaysBeforeLeaseEnd is property-only, no company layer. settings-resolver.ts header states

only tourDurationMinutes and notifications.observerBcc have a company tier (plus company-only applicationLinkMode); "every other field below is unchanged — still property-only, no company layer." CERTAIN.

  • Per-property enrollment, no bulk mechanism. renewal-auto-start.ts:405-422: "Per-property opt-in: the single

enforced gate is the property's autonomousRenewalEnabled master switch... One toggle per property — there is no separate outreach flag." A property is scanned if it opted into renewal OR holdover (:436); if none has, the scan exits (:439). CERTAIN.

  • Policy rows are per-property (PK=PROP#{propertyId}) with no org default. organizationId appears in the

eligibility layer only as an audit field on TenantRenewalFeatures (eligibility/features.ts:21), never as a lookup key. CERTAIN.

  • Leasing "intake mode" was only ever the single-building shape. listing-resolution.ts:118-123: 'single'

mode — "the line means ONE property with apartments in one building... every property live today: Camellia, Yale, the Willows." CERTAIN for the comment; LIKELY that renewal copy inherits the assumption.

  • isSingleHome / isHomeAddressForWording (listing-resolution.ts:108-112,

resolve-prospect-email-branding.ts:115,126,132) govern EMAIL BRANDING WORDING only and are referenced by no renewal file. CERTAIN. Caution: renewal-policy-single-home.test.ts / .drift.test.ts are about "one home for renewal facts" (the legacy-vs-versioned consolidation), NOT single-family housing.

2.4 AppFolio tier / access assumptions

  • No "lower tier / no DB API" comment is co-located with renewal code, and **no live renewal code references

westernslopepm. CERTAIN** (grep).

  • The real assumption is the write path: triggerRenewalSync requires Property.pmsSource==='appfolio' (the sole

push-to-PMS gate) AND Property.pmsAppfolioAccount (fail-closed), and the external appfolio-browser-agent must hold a logged-in session for that subdomain; only jpco and situsgroup are named as live (l4-core.ts:3467,3703). CERTAIN for the mechanism; LIKELY that WS has neither the account row nor a robot session, since its access is read-only today.

  • Correction worth recording, because it is easy to get wrong: dispatchAppFolioWrite

(activities/renewal.ts:2306) IS declared-and-never-called — but that is the WORKFLOW-OWNED dispatch behind the workflowOwnedDispatch arm (OFF). The LIVE offer-preparation path is the caller-side SQS dispatch in §1.5, and it runs in production today. CERTAIN.

  • AppFolio hard rules from docs/runbooks/debugging-a-stuck-renewal.md: never silently default a value AppFolio

renders (missing policy = fail loud); send ALL terms from policy (termOptions: number[], one Renewal Option per term); MTM premium is a separate fee, never rolled into rent; Form Template only — the lease document is single-term, multi-term lives at the Renewal Options layer; a term change requires cancelling the previous offer first, which AppFolio otherwise refuses; reset requires BOTH sides (AppFolio cancel + PropFlow saga + delete terminal sagas).


3. What breaks or must change for a centralized multi-property org

S ~ config or a small PR. M ~ one focused PR chain. L ~ design plus several PRs.

# Item Effort Label What has to happen
1 Board refuses All-Properties. Kat/Bev would pick one of ~70 buildings at a time. M CERTAIN (board-scope-gate.ts:33-39) An org-scoped renewals list or a portfolio roll-up. The loader already supports propertyId=undefined (store.ts:3987), so this is a view, not a data change. Cohort math stays per-property.
2 Per-property enrollment x 70. autonomousRenewalEnabled is one toggle per property, no org bulk enable. M CERTAIN (renewal-auto-start.ts:405-436) Per the repo's "one per-property switch, or none" rule the switch must stay per-property and visible, so the work is an org-level set-many affordance and/or an inherited default that still renders per property — not a second switch.
3 Policy rows x 70, no org default. PK=PROP#{propertyId}/RENEWAL_POLICY#{v}; each home needs thresholds, term options, rent strategy, MTM premium. L CERTAIN (dynamo/renewal-policy.ts:10, facts-store.ts:49) Add an org tier with nearest-wins (property > org > default), mirroring pm-contact-email / tour-hours. Must preserve replay integrity: EligibilityResult.policyVersionId is what makes a decision replayable, so an org row needs its own version identity.
4 Quiet hours / timezone default is Camellia's. Unset Property.timezone -> America/Chicago. S CERTAIN (quiet-hours.ts:26-32) Set timezone on every WS property (all Mountain) AND change the fallback to resolve org, then refuse, rather than defaulting. TCPA-hours exposure, not cosmetics.
5 AppFolio write-back is behind our own read-only lock, not a missing account. The robot already signs in to westernslopepm. Writes are blocked by a read-only lock we set on 2026-09-08 because Western Slope has no test property. L CERTAIN mechanism / Fede gate Turning it to live is one setting on the account row in the browser agent, Fede's call, no build and nothing for Western Slope to do. Until then WS renewals can only OBSERVE (the read-only rent-roll poller path works), and outreach would be talking about an offer nobody created.
6 Escalation SMS has no org rung. renewalContactPhone is property-only, "no fallback". S CERTAIN (renewal-notify.ts:15) Add the org rung the email chain already has, or accept email-only for WS.
7 WS's escalation email resolves to an internal placeholder today. S CERTAIN (go-live tracker, 2026-09-15 row) Set Organization.escalationOwnerEmail — the chain's third rung already exists. Config, not code.
8 The on-site-team design premise. The whole "page a human once, then wait" cadence was built for a team that does not engage. L CERTAIN for the code shape (activities/renewal.ts:2432 sole writer, no readers) An engaged centralized team makes escalations likelier to be answered, but it also means the "forwarded question" class (counter-offers, paper-lease asks) finally has an owner — so the tracked-matter / chase half has to exist rather than be optional.
9 Roles are consulted nowhere in renewal escalation. No org_admin, property owner, or PMS-credential-holder read in pm-contact-email.ts, renewal-notify.ts, renewal-auto-start.ts or the Clara handlers — recipients resolve purely by email-field chain. Only role check in a renewal-adjacent path: requireRole(request,'conversations','edit') at renewal-change-reviews/[id]/decision/route.ts:102. M CERTAIN (grep: NOT FOUND) If "who answers for this renewal" should be a person with a role at a centralized org, that is new. This is exactly the surface Gera's portfolio model defines — name the conflict on the go-live tracker rather than building against a different model.
10 capabilityStage.renewals declared but not wired. activities/renewal.ts:113: "isDomainOutboundSuppressed(property,'renewals') is declared but not yet wired here... the existing autonomous-send gate remains the sole renewal send gate." S CERTAIN Wire it or delete it. The staged rollout WS was onboarded under (leasing -> renewals -> maintenance) has no enforcement inside renewals today.
11 Module-gate coverage is uneven across renewal channels. send-renewal-email.ts:283 declares MODULE_FLAGS.LEASING_RENEWALS; initiate-renewal-call.ts:101 declares 'leasing.renewals'; send-renewal-letter-mms.ts, recap-sms.ts and sms-cascade.ts declare no module. S CERTAIN (grep) With leasing.renewals off at WS, email and voice are held fail-closed by company-live-gate.ts / initiate-outbound-call.ts; the MMS letter and recap SMS doors do not ask. The letter MMS is the FIRST outbound in the ladder, so it is the one that would reach a WS resident.
12 Specialist routing has no module check. The renewals specialist engaged without asking whether the module was on; #11124 fixes billing, a general module-check-first PR is pending, remaining ungated legs in #11307. S-M CERTAIN (go-live tracker, 2026-09-23) Land the general fix before renewals are discussed on WS's line at all.
13 Two customers on two policy paths. Camellia is denylisted from the facts migration; WS would onboard onto the versioned path. M CERTAIN (refused-property-ids.ts:37-40) Workable short-term, but every renewals change must be proven on both paths, and the four-disagreeing-defaults problem (§2.2) is live on the legacy side.
14 MTM / lease templates / term handling. Form Template only; multi-term at the Renewal Options layer; a term change needs the prior offer cancelled first. M CERTAIN (runbook hard rules; contracts/change-term/scenarios.json) WS's five lease templates disagree on MTM notice (21 vs 30 days, per the knowledge-review pass), so MTM premium and notice facts must be settled per property before holdover is even discussed. holdoverConversion stays off.
15 Mailbox and phone attachment at org level. Reply mailbox already does property -> company shared mailbox (property-graph-sender.ts:511,677,734, "the building always wins"); outbound address already does property -> org-inherited line (resolve-outbound-address.ts:145-155, mechanism at :33-35). S CERTAIN mechanism / CERTAIN gap resolve-outbound-address.ts:9-11 records that no org has an ATTACH#phone_line row configured today — the org-line path is untested in production, and WS is the first org that needs it.

What does NOT break — most of the shape. The workflow engine, the saga read-model, the person-keyed GSI, the eligibility engine, the send guard, the six scope-pin checkpoints, the reply-mailbox chain, the PM-contact email chain with its org rung, tour hours' org tier, per-property AppFolio account resolution, and the module entitlement resolver are all already org-aware or org-indifferent.


4. Skeletons and technical debt

4.1 Half-shipped arms

  • workflowOwnedDispatch — still OFF, opt-in allowlist. types.ts:16087-16093: "Default OFF... proven on

Willows/appfolio-45 BEFORE any live property is added." Nothing in the repo shows a live property in the allowlist. Meanwhile its workflow-side patch renewal_workflow_owned_dispatch_v1 was deprecated as UNCONDITIONAL on 2026-09-21 (#10431) — so the patch fork is gone while the arm doc still describes the behaviour as off-by-default. Those two statements need reconciling. CERTAIN. Practical effect: in prod only the caller-side SQS dispatch is exercised, and dispatchAppFolioWrite is dead code in the workflow.

  • autoStart, autonomousSending, holdoverConversion all default false and nothing in-repo asserts their

production values. To a reader of the repo alone the whole engine is dark. CERTAIN.

  • Inert arm fields in the live config row (--holdover-in-workflow, --walker-spawn and pairs) —

set-renewal-arms.ts:31-39. CERTAIN.

4.2 Patch ledger and retireBy

Ledger: src/__tests__/temporal-patched-ledger.drift.test.ts (1,067 lines), inside the required Unit Tests gate. It statically scans src/lib/temporal/workflows/ for patched() / deprecatePatch() and cross-checks a PATCHED_LEDGER object for entry existence, file, coverage kind, valid dates and substantive notes. Enforcement is two-stage (:68-77, :978-1017): past retireBy it only console.warns; it fails CI only after retireBy + RETIRE_GRACE_DAYS (14, :95).

No renewal patch is past retireBy as of 2026-09-23. Three are within a week: countersign_reminder_requires_signature_v1 (renewal-workflow.ts:1730) -> 2026-09-29; renewal_commitment_stated_phase_v1 (:1704) -> 2026-09-30; renewal_negotiating_intent_v1 (:2089) -> 2026-09-30. Further out: renewal_infra_classifier_budget_v1 (:2900) -> 2026-11-02; renewal_scope_pin_v1 (:2678) -> 2026-11-19; v2 (:1696), v3 (:3506), v4 (:1668) -> 2026-11-20. Already retired via deprecatePatch: 20 ids, most recently six on 2026-09-19 (incl. renewal_honor_pause_v1) and renewal_workflow_owned_dispatch_v1 on 2026-09-21. No getVersion( anywhere (older API unused). CERTAIN.

4.3 TODO / FIXME / deprecated in renewal code

  • activities/renewal.ts:113 — TODO(G-1): wire capabilityStage.renewals.
  • activities/renewal.ts:1113 — TODO(Phase 6.4): pass intent via activity input so this lookup can drop.
  • Four TODO(saga-migration) markers, each riding an eslint-disable no-restricted-syntax, all pointing at the same

unfinished move off a deprecated tenant field onto getRenewalView: renewal-cycle.ts:399, renewal-status-display.ts:337, RenewalDetailClient.tsx:934, board/_components/map-renewal.ts:698.

  • src/lib/data/renewal-saga/writer.ts:318 — @deprecated alias OpenObservedSagaParams.

CERTAIN.

4.4 Duplicated mechanisms (Constitution: one mechanism per signal)

  • Dispatch owner is forked by design: caller-side triggerRenewalSync (renewal-auto-start.ts:1966,

approve/route.ts:763, outreach/route.ts) vs workflow-owned dispatchPmsPrepare (renewal-workflow.ts:3052). Arm-gated and deliberate, but two live paths for one signal. CERTAIN.

  • Two homes for the delinquency threshold, four disagreeing defaults (§2.2). The clearest live violation.
  • renewal-workflow.ts hard-codes RENEWAL_NON_RETRYABLE_ERROR_TYPES inline at :690 and :735 instead of

importing the shared constant from retry-policies.ts:7-11 that every sibling workflow imports — a silent lock-step drift risk. CERTAIN.

  • approve/route.ts and decision/route.ts are one handler under two URLs (deliberate back-compat alias).
  • Resolved duplicates, worth knowing they are closed: the second renewal starter was DELETED, not disarmed

(#6902, 2026-09-04) — one starter now; holdover ownership collapsed onto the daily scanner (renewal-auto-start.ts:934, renewal-workflow.ts:3710); the cohort walker evaluates and persists, never spawns; declines funnel through renewal-declined-tenant.ts:14 ("the single source of truth"). CERTAIN.

4.5 Skipped tests

Across all 171 test files matching renewal|arms|countersign|holdover, exactly one skip: src/__tests__/agent-renewal-eval.test.ts:195 — const describeLive = LIVE ? describe.sequential : describe.skip, gated on RUN_LIVE_EVALS==='true'. A live-call eval gate, not an abandoned test. No unconditional it.skip / describe.skip / xit / .todo anywhere in the renewal surface. CERTAIN.

4.6 Eslint suppressions

16 renewal-related entries in eslint-suppressions.json, mostly test-file no-explicit-any. Non-test: RenewalDetailClient.tsx (no-restricted-syntax x5, the saga-migration TODOs), renewal-workflow.ts (no-explicit-any x7), RenewalPolicyEditor.tsx (react/no-unescaped-entities x1). CERTAIN.

4.7 Behavioural debt the code alone will not show you

The 2026-09-03/04 renewals-architecture-decision page (~/.claude/propflow-docs/artifacts/renewals-architecture-decision-2026-09-03.html) catalogues ~50 issues since launch as BUG / DESIGN FLAW / INCOMPLETE / DESIGN DECISION. Its own TL;DR: about a third genuine bugs, a third designs that did what they were told and what they were told was wrong, a third half-built. Doc-sourced and dated 2026-09-04; several entries there are already marked fixed and live (archived-saga bug, duplicate starter, duplicate-send guard, intent-evidence module). The shapes that matter most for a CENTRALIZED, ENGAGED team, because those are precisely the ones such a team will hit:

  1. "Tell a human once, then wait" — A1: five offers never sent for want of an email on file; one PM email, a

65-day suppression marker (retry-failed-prepare.ts:818), a 30-minute retry cron that skips on the marker.

  1. The PM-flag field has one writer and zero readers — A2, sole writer activities/renewal.ts:2432. **Confirmed

still present, CERTAIN.**

  1. Escalation is fire-and-forget by construction — B3/B4: no tracked matter, no deadline, no re-page. A

counter-offer and a paper-lease request each sat for weeks.

  1. Reminder hold never lifts early — B5, named in renewal-outreach-hold.ts's own header.
  2. Reachability module has zero callers — A4: src/lib/domain/leasing/renewal-reachability.ts

(evaluateRenewalReachability); channels are chosen once at renewal-auto-start.ts:1220 and never re-evaluated. Confirmed file present, CERTAIN.

  1. Contact-info changes never re-open a stuck renewal — A5: saveTenants discards the did-it-change result

(src/lib/data/dynamo/property.ts:681-700).

  1. Nothing closes a renewal when its lease is archived — E1.
  2. The 7-in-7-days message ceiling is measureOnly: true for renewal letters — F3,

send-renewal-letter-mms.ts:200-221, 265-289; policy decision pending since 2026-09-03. Confirmed, CERTAIN.

  1. The external reconciler's observe half works; the apply half is unproven, and the "looks handled — confirm?"

button contradicts Fede's 2026-09-03 follow-the-PMS-signal ruling — C2/C3.

Items 2, 5 and 8 I re-verified against current files (CERTAIN). The rest are doc-sourced as of 2026-09-04 and should be re-read before being quoted as today's state.


5. Where it is working well

  • One starter, one owner. The second auto-starter was deleted, not disarmed (#6902). The cohort walker cannot

start a renewal. renewal-entry-point-spine.drift.test.ts pins it. The root of the Sept 3 early-offer incident is structurally closed. CERTAIN.

  • The workflow is the sole writer of lifecycle state, through a saga writer with an optimistic version lock; the

rent-roll poller emits saga EVENTS rather than competing writes (pms/writers/renewal.ts header), every branch idempotent on pmsRef.

  • Replay corpus. src/__tests__/renewal-workflow-replay-corpus.test.ts replays committed real Temporal histories

(src/__tests__/fixtures/temporal-histories/) against current code. Plus a nightly prod-history canary — see §6.9.

  • Permutation harness with a committed golden baseline —

src/__tests__/workflow-permutations/renewal/{renewal-adapter.ts,renewal-scenarios.ts,baselines/renewal.golden.json}.

  • Ten renewal drift tests pinning structural invariants: renewal-entry-point-spine,

renewal-outbound-non-renewal-gate, renewal-outreach-stage-stripped, renewal-policy-single-home, renewal-replay-ci-paths, renewal-saga-personid-rekey, renewal-source-stripped, renewal-voice-optout, renewal-worker-deploy-gate, tenant-renewal-timestamps-stripped.

  • arms-registry-coverage.test.ts structurally scans src/ for every createDomainGate(...) call site and

every autonomous*Enabled property flag and fails CI if either is unregistered in the Arms admin tab — it closes the "invisible gate" class directly (cites #5801/#5904 at :21-27). The best piece of self-governance here.

  • Regression suite src/__tests__/regression/renewals/ — 9 files plus a manifest, spanning board selector, PMS

writer accounting, reconciler escalation, eligibility, workflow, outreach, Clara classification, voice, API route (npm run test:renewals-regression). 161 renewal test files overall, 20 on the workflow, 9 on countersign.

  • Scope pins (p9-workflow-pins). Six named checkpoints where a running saga re-resolves which company it

belongs to before a send (activities/renewal-scope.ts:11-46), with residual gaps stated in the file rather than left to be inferred. The pattern to copy for the multi-org work.

  • Per-property AppFolio account resolution is fail-closed (resolve-account.ts) — a mis-scoped renewal offer

cannot land in the wrong customer's database by default. Exactly the property a second AppFolio customer needs.

  • The PM-contact email resolver is already the nearest-wins org resolver we want elsewhere

(pm-contact-email.ts:1-38): property renewal contact -> property inbox -> Organization.escalationOwnerEmail, folded in ONE place, with organizationId read off the property row and never off the caller's session.

  • The module entitlement seam (domain/modules/resolve-module-entitlement.ts,

integrations/email/company-live-gate.ts, integrations/voice/initiate-outbound-call.ts) — one shared fail-closed question asked at doors that already exist, instead of a new central checkpoint. Renewal email and renewal voice already declare it.

  • Willows is physically unable to write into JP's real AppFolio (recording stand-in), which is what makes a real

end-to-end proof safe to run (2026-09-04 page).


6. Temporal usage specifically

6.1 Workflow ids, task queue, schedules

  • renewalWorkflow — workflows/renewal-workflow.ts:1035; registered type string 'renewalWorkflow' (:464).
  • Task queue: process.env.RENEWAL_TASK_QUEUE_OVERRIDE || 'propflow-renewal' (client.ts:135,

workers/renewal-worker.ts:99, services/renewal-worker/index.ts:202-204).

  • Workflow id, two modes:
  • Unarmed: workflowId = input.renewalId (the minted saga id) — client.ts:206-214.
  • Armed (ADR-0108 D2, ON by default since 2026-09-03): renewal-${leaseId}-${renewalMintCycleKey(leaseEndDate)}

via stableRenewalWorkflowId (client.ts:153-155), called from renewal-auto-start.ts:1846, and stamped onto saga.workflowId. resolveSagaWorkflowId(s) = s.workflowId ?? s.id (src/lib/data/renewal-saga/workflow-id.ts:27).

  • workflowIdConflictPolicy: USE_EXISTING; workflowIdReusePolicy: 'ALLOW_DUPLICATE' (re-open after a terminal

run); signal-first path signalWithStart at client.ts:402-412.

  • renewalAutoStartScanWorkflow — workflows/renewal-auto-start-workflow.ts:36; started only by a Temporal Schedule

(upsertRenewalAutoStartSchedule, client.ts:575-624): fixed id renewal-auto-start-daily-wf (:593), ScheduleOverlapPolicy.SKIP (:601), workflowExecutionTimeout: '20 minutes' (:596). Operator CLI scripts/temporal/upsert-renewal-auto-start-schedule.ts, default cron 0 9 * UTC. The schedule only controls firing — the run still no-ops unless the durable autoStart arm is armed.

  • The cohort walker and the tour/turnover walkers have their own schedules; pausing a schedule is the only gate for

the cohort walker (no env flag).

6.2 Activities and retry policies

Activities in activities/renewal.ts: sendOutreachSms:751, sendReminderSms:1038, sendOutreachEmail:1432, sendRenewalLetterMmsActivity:1699, dispatchVoiceCall:2001, dispatchAppFolioWrite:2306, dispatchPmsPrepare:2363, classifyPrepareFailure:2432, escalateToPM:2488, loadSagaInitialState:2870, resolveLeaseEndIsoActivity:2984, resolveSendWindowDelayActivity:3009, recordWorkflowPhase:3089, recordWorkflowOutcome:3207, transitionSagaToOfferPrepared:3850, escalateHeldRenewalActivity:3964. Plus processCohortWalkActivity (renewal-cohort-walker.ts:58) and processRenewalAutoStartActivity (renewal-auto-start.ts:25).

proxyActivities groups in renewal-workflow.ts:

Group Line Timeout Retry
shortActivities :660 (opts :683-692) startToClose 60s initial 1s, coeff 2, max 60s, attempts 5, nonRetryable ['InvalidPayload','InvalidCredentials','PermissionDenied']
projectionActivities (recordWorkflowPhase) :701 (:706-712) startToClose 5s initial 500ms, coeff 2, max 2s, attempts 2 — no nonRetryable list
longActivities (voice, AppFolio write, letter MMS) :722 (:727-736) startToClose 10m, heartbeat 60s initial 2s, coeff 2, max 2m, attempts 5, same nonRetryable list
pmReminderActivities :744 (:749-755) startToClose 30s initial 1s, coeff 2, max 30s, attempts 3 — no nonRetryable list
auto-start scan renewal-auto-start-workflow.ts:10-27 startToClose 15m initial 2s, coeff 2, max 5m, attempts 5, [...RENEWAL_NON_RETRYABLE_ERROR_TYPES] — the file's own comment notes this list is inert because the activity wraps every error as TransientUpstream

Shared constant RENEWAL_NON_RETRYABLE_ERROR_TYPES lives at retry-policies.ts:7-11 and is imported by the auto-start and cohort-walker workflows — but NOT by renewal-workflow.ts, which duplicates the literal inline at :690 and :735. Drift risk. CERTAIN.

6.3 Signals, updates, queries

All in renewal-workflow.ts. There is no separately-named accept / decline / counter / change-term / pause / countersign signal — those are payload field values, not distinct signals:

  • pmsChangedSignal 'pmsChanged' :466 · pmsFailedSignal 'pmsFailed' :467 · deliveryStatusSignal 'deliveryStatus' :468
  • tenantIntentSignal 'tenantIntent' :469 — payload kind: 'renew'|'vacate'|'negotiating', plus condition and

requestedTerm: 'mtm'|number. Accept, decline, counter and change-term all live here.

  • pmActionSignal 'pmAction' :470 · pmStatusOverrideSignal 'pmStatusOverride' :471 — payload

status: 'paused'|'resumed'|'manual_renewed'|'manual_declined'. Pause lives here.

  • Updates: retryLetterUpdate 'retryLetter' :492 · externalResolutionUpdate 'externalResolution' :549 ·

pmDecisionUpdate 'pmDecision' :586 (PM approval).

  • Query: getSnapshotQuery 'getSnapshot' :654.
  • Registration: a generic dedup() wrapper at :2118 registers each signal (:2125 pmsChanged, :2148 pmsFailed,

:2149 deliveryStatus, :2150 tenantIntent last-write-wins, :2151 pmAction, :2157 pmStatusOverride); setHandler(retryLetterUpdate,...) :2163; shared factory makeResolutionWrapupHandler :2281 wired for externalResolutionUpdate (~:2314-2321) and pmDecisionUpdate (~:2325-2331); setHandler(getSnapshotQuery, buildWorkflowSnapshot) :2357.

  • Countersign is derived state, not a signal: pms() === 'countersigned' via countersignTerminal()

(~:1798-1804), fed by pmsChangedSignal from the rent-roll poller and the AppFolio countersign email parse.

6.4 Timers, sleeps, conditions

Wait Line Duration
Pause wait `condition(() => !isPaused() \ \ isSettledOrTerminal())` :1356 no timeout — holds indefinitely on PM action
Countersign wait :1797 / :1807 default 14 days; re-arm loop :1818-1823
Held/escalated wait condition(isSettledOrTerminal) :2471 no timeout
Held-review wait :2495-2499 leaseEnd + 24h grace — potentially months
Prepare timeout (RENEWAL_PREPARE_DEFAULT_TIMEOUT) :2981, :3057 from prepare-retry-budget.ts
Prepare retry backoff sleep() :2935 / :3039 default 6 minutes
Send-window delay (quiet hours) :3197 / :3212 dynamic
Awaiting response :3459-3460 default 24 hours
Reminder step 1 :3484 / :3509 24 hours
Reminder step 2 :3485 / :3536 7 days
Expiry-nudge ladder (resolveExpiryNudgeWait) :3613-3627 lease-end -30d and -15d — real sleeps up to ~30 days
Outer ceiling client.ts:184 workflowRunTimeout = '400 days'

6.5 Versioning and patching

patched() / deprecatePatch() only — no getVersion( anywhere. Live and retired ids, the ledger mechanism and the 14-day grace are in §4.2. Note the stale-premise warning the code itself carries (client.ts:220-250): ~12 in-file comments justify "the pre-patch cohort is drained" from "60 days have passed since landing", arithmetic that predates the 60d -> 400d run-timeout bump and is no longer sufficient. Drain must be verified against Temporal visibility on the CURRENT namespace propflow-prod.hmqma (the old propflow-renewal-prod.hmqma is retired read-only and reports 0 RUNNING for everything, which proves nothing). CERTAIN.

6.6 continue-as-new / unbounded history

No continueAsNew() call anywhere in renewal-workflow.ts (zero grep hits). It is a known, documented gap — client.ts:216-220: "400d is a generous cushion, not a real bound — a workflow parked indefinitely for PM action can still eventually hit it; a follow-up (continueAsNew before the ceiling) is the real fix and remains out of scope here." Combined with the two no-timeout condition() waits and the multi-week timers in §6.4, one renewal saga can accumulate history for most of a year with no escape valve. The single biggest structural fragility. CERTAIN. Same comment records 17 renewalWorkflow executions RUNNING in prod as of 2026-09-03, so long-lived renewals are empirically normal.

6.7 Determinism

  • Date.now() at :1818, :1822, :2499, :3619 — explicitly documented as safe (:3572-3573,

expiry-nudge-schedule.ts:5): the SDK patches it for replay. CERTAIN, accepted rather than accidental.

  • No Math.random(), no uuid, no direct fetch/IO in any of the four core workflow files. CERTAIN.
  • Activity modules are imported into workflow files as import type only (renewal-workflow.ts:258,

cohort-walker-workflow.ts:15, renewal-auto-start-workflow.ts:5) — erased at compile time, so no activity-layer IO enters the deterministic sandbox. The only non-type runtime imports beyond @temporalio/* are the pure helper resolveExpiryNudgeWait (:249) and the pure constant RENEWAL_PREPARE_DEFAULT_TIMEOUT (:257). CERTAIN.

6.8 Worker deployment

services/renewal-worker/index.ts is a generic multi-worker runner: one shared connection and one shared workflow bundle (:293-297), then Worker.create() per entry of a 17-queue registry (:188-223, :301-331) including propflow-renewal with renewalWorkerActivities (:202-204). Worker identity ${taskQueue}-${buildId}-${ecsTaskId} (:316).

  • ECS task def infra/temporal-worker/task-definition.template.json: ARM64 Fargate, cpu 512, memory 3072

(:5-10), image tag placeholder (:16), stopTimeout 120 (:18), ~25 Secrets Manager/SSM secrets (:42-134), awslogs /aws/ecs/propflow-renewal-worker-__ENV__ (:136-144), health check node /app/healthcheck.js 30s/10s/20s/3 (:145-151). The task-def template is on the auto-merge guard's DANGEROUS_GLOBS list because it selects IAM roles.

  • Graceful drain: per-worker shutdownGraceTime: '25s' (index.ts:326; renewal-worker.ts:110), SIGTERM/SIGINT

handler (:403-404) calls shutdown() (:384-402) on all 17, backed by a 50-second hard-exit watchdog (HARD_EXIT_WATCHDOG_MS = 50_000, :381) forcing process.exit(0) — added after a confirmed 2026-08-29 incident where the propflow-promise worker never left DRAINING.

  • Concurrency: maxConcurrentWorkflowTaskExecutions: 10, maxConcurrentActivityTaskExecutions: 10

(renewal-worker.ts:112-114), labelled "Phase 0 defaults; production tunes these."

  • Versioning: buildId from PROPFLOW_GIT_SHA || GIT_COMMIT || fargate-<ts> (:152-158);

useVersioning = TEMPORAL_USE_VERSIONING === 'true' (:260) — OFF by default per the README's canary note; strict pinning needs a manual temporal task-queue versioning add-new-default.

  • Activity registration: workers/renewal-worker-activities.ts:44-53 is the single shared map between the local dev

runner and the Fargate entrypoint, extracted after a prod incident where pinRenewalScopeActivity existed only in the dev map ("Activity function ... is not registered on this Worker", header :1-22).

  • Alerting: Sentry wired directly (index.ts:94-104), DSN in the task-def. Replay divergences page Slack #alerts

(C0BGTNSP04C) from CI, not from the worker.

6.9 Replay testing

Pipeline scripts/temporal/export-recent-histories.ts -> scrub-history-fixture.ts -> replay-history.ts. Export fails hard on zero histories (its own comment :33-40 records the earlier silent bug that ran green for three weeks testing nothing). Scrub rounds dollar fields to 1000 and redacts worker identity / sticky-queue strings (:27-36, :73-78). Replay extracts the real task queue from the history's own WorkflowExecutionStarted event (history-task-queue.ts:14-37) because Worker.runReplayHistory otherwise synthesizes a fake queue name that can flip literal-task-queue branches, then replays against current code and exits 1 on divergence. CI: .github/workflows/nightly-renewal-harness.yml job prod-history-replay-canary (:544-703), nightly, --workflow-type=renewalWorkflow --limit=10 --closed-within-days=30, Slack on divergence. Committed fixtures src/__tests__/fixtures/temporal-histories/: intent-only-countersign-wait-prechange.json (125 events), postcutover-holdover-reoffer.json (27 events), plus three retired wedge-incident fixtures. Coverage gaps: only branches exercised by the last 10 closed histories in 30 days; never executes activities, so business-logic bugs are out of scope; a zero-export night fails the job but sends no divergence alert.

6.10 Fragility

CERTAIN

  1. No continueAsNew against a 400-day run timeout, with two no-timeout waits and multi-week timers (§6.6).
  2. renewal-workflow.ts duplicates the non-retryable error-type list inline instead of importing the shared one.
  3. 17 Temporal Workers share one Node process, connection and bundle — a hang in one queue threatens drain for all,

which is why the 50s hard-exit watchdog exists.

  1. The patch-ledger gate warns silently for 14 days past retireBy before it reds CI.
  2. Worker useVersioning is OFF by default — no build-id pinning during a deploy over a 14d-400d in-flight run;

safety rests entirely on patched() discipline.

  1. The nightly replay canary silently loses coverage on a zero-export night.
  2. The "60 days have passed" drain arithmetic in ~12 in-file comments is stale since the 400d bump (§6.5).
  3. Three renewal patches retire 2026-09-29/30.

LIKELY

  1. Held-review parks (lease-end + grace) plus no continue-as-new could produce genuinely large histories; no

history-size incident found in the files read.

  1. Concurrency limits are self-described "Phase 0 defaults"; possibly under-tuned, no throttling evidence found.

7. Ranked answer to "what are we up against"

  1. Turning on AppFolio writes is Fede's call, not a Western Slope task. The robot already signs in to westernslopepm. Writes

are blocked by a read-only lock we set on 2026-09-08 because Western Slope has no test property; flipping it to live is one setting on the account row in the browser agent, Fede's call, no build and nothing for Western Slope to do. (L, CERTAIN mechanism / Fede gate.)

  1. Fan-out config: enrollment, policy and timezone are all per-property with no org tier. ~70 properties x

(one toggle + one policy row + a timezone) is the bulk of the wiring work, and two of the three have no set-once mechanism at all. (M/L, CERTAIN.)

  1. The board does not exist for a portfolio. A two-person centralized team cannot work a per-property cohort

board across ~70 buildings; the loader can already go org-wide, the page refuses to. (M, CERTAIN.)

  1. The escalation half was built for a team that never answers, and WS's team answers. That flips the

tracked-matter / reminder / reachability work from "nice" to the main thing — for the first time there is somebody to chase, and somebody who will notice when nothing chases them. (L, CERTAIN for the code shape, doc-sourced for the incident history.)

  1. Small but sharp, all S, all CERTAIN, all cheap: the America/Chicago quiet-hours default (TCPA); the letter

MMS and recap SMS doors that do not ask the module gate while email and voice do; capabilityStage.renewals declared and unwired; escalation SMS with no org rung; WS's escalation email still pointing at an internal placeholder; the renewals specialist's missing module check (#11307).

  1. Calendar: three renewal Temporal patches retire 2026-09-29/30 — inside the window this work would run.

Process note: one research subagent reported encountering, and correctly disregarding, a spoofed mid-task message purporting to come from another session and asking it to resend its findings. No action was taken on it and it did not affect any content above. Worth knowing that such a message appeared.

History of the feature

Source: scratchpad/report-history.md

Renewals: a history of the feature

Read-only archaeology of the PropFlowAI repo, published docs, and prior session memories. Compiled 2026-09-23 against origin/main (fetched 2026-09-23).

Method note on counts. Two commit sets are used throughout:

  • Strong set = commits whose subject line names renewals (renewal, renewals, countersign, holdover, month-to-month, mtm, NTV). 810 commits. This is the honest measure of renewal work.
  • Union set = strong set plus every commit touching a path matching renew, lease-end, countersign, mtm, month-to-month. 2,399 commits. This over-counts: renewal files are touched incidentally by sweeping refactors (spine collapse, org scoping, DataTable migration).

PR numbers are taken from the (#NNNN) suffix squash-merge leaves on the subject.

Month Strong commits Strong unique PRs Union commits
2026-03 74 0 (pre-PR era) 181
2026-04 127 112 296
2026-05 157 157 478
2026-06 114 114 256
2026-07 202 201 577
2026-08 40 40 264
2026-09 (to 09-23) 96 96 347
Total 810 720 2,399

March has zero PR numbers because the team was still committing straight to main and merging local branches (Merge branch 'renewals-p1' etc.); the PR-per-change workflow starts in April.


1. Dated timeline

2026-03 — the Renewals tab (UI only, pre-PR workflow)

  • 2026-03-04 Split leasing tab into New Leases and Renewals subtabs — the first renewals surface.
  • 2026-03-09 feat: Renewals tab Phase 1 — KPIs, columns, filters, sort, conversation modal + first smoke/e2e suite. This is the earliest commit whose subject names renewals.
  • 2026-03-17/18 Renewals Phase 2: auto-outreach, RENEWAL_STATUS constant, ACCEPTED status between OUTREACH_SENT and RENEWED, balance/MTM/total-upcoming columns, demo seed script, manual "Send Outreach" button for demos, outreach timing moved to Settings.
  • 2026-03-18/19 The renewal intent classifier is rewritten twice in two days: regex → LLM, then "pure LLM with conversation history, no regex". Renewal overhaul: decline confirmation, warm messaging, cadence settings, status rename.
  • 2026-03-20/22 Race and consistency bugs: decline stuck at outreach_sent, DynamoDB GSI eventual-consistency in renewal-trigger, Sentry capture added.
  • 2026-03-25/26 UX audit chunk 5 (Prospects + Renewals overhaul), per-property renewal outreach in AppSettings, MTM indicator, turnover auto-create from declined renewals.
  • Character of the month: hand-merged local branches (Merge branch 'renewals-p1'), demo-driven, no PR numbers.

2026-04 — autonomous outreach scaffolding, AppFolio writes, the first guard (112 PRs)

  • 2026-04-14 #91 Approve → AppFolio sync, new renewal statuses.
  • 2026-04-17 #128 autonomous outreach scaffolding + AppFolio test-property pipeline.
  • 2026-04-19 #202 callback fires concierge SMS, flag-gated off (the first renewal gate). #229 CloudWatch alarms for PMS consumer + renewal pipeline. #224 Clara-branded policy card.
  • 2026-04-20 #278 auto-decline on NTV filed. #271 daily cron migrated launchd → Lambda + EventBridge.
  • 2026-04-21 #302 voice toolkit cut to 5 tools. #313 SMS + voice share one Clara agent, Haiku classifier retired. #314 PREPARING / PREPARE_FAILED / ESCALATED states + auto voice call on prepared. #324 REVERT of #313's SMS half — a narrow renewal-only prompt meant a mid-renewal tenant saying "my toilet leaks" got no maintenance tools. Evidence was Fede's own dogfood at 2026-04-21 18:34 UTC ("why did rent go up so much?" → Clara fell into generic deflection). Voice kept the dedicated prompt; SMS went back to the unified prompt with a renewal block appended.
  • 2026-04-22 #366 removes the NEVER-WRITE-TO-CAMELLIA allowlist guard — the moment renewals could write to a real customer's AppFolio. #361 AppFolio offer becomes source of truth for the voice call. #355 runtime switch on the approve route. #356/#359 CI auto-syncs the renewal voice prompt and tool registrations to ElevenLabs on merge.
  • 2026-04-23 #384 minimum-increase policy (% or $/mo). #381 real Day-2 SMS nudge scheduler. #376 real AppFolio signature state on the detail page.
  • 2026-04-26/27/28 The hardening burst: #429 intent-to-vacate from operational emails, #422 email fallback for no-phone tenants, #467 per-channel outcome stamps + SMS→Email cascade, #470 detect manually-created PM offers, #458 detect countersign via AppFolio renewal_summary, #471 multi-term offer pitch parity, #469/#478 runtime hallucination guard ("legal-risk defense for live tenant outreach"), #493 auto-fire renewal_escalate when the hallucination guard blocks, #491 change_renewal_term, #496 auto-send the AppFolio Renewal Notice Letter as MMS, #499 per-property voice-call toggle, #503 preparedOffer.occupancyId as the single AppFolio identity.
  • 2026-04-29/30 #535 renewal E2E canary "verifying every 2026-04-29 failure" (there was a bad day on 04-29). #538 RenewalSaga types + pure state machine. #554 the saga becomes the single source of truth for status + KPIs.
  • Docs artifact appfolio-renewal-benchmarks.html was last generated 2026-04-24T01:34:45Z: 60 L2_DOM trials, 0.0% success, $32.66 spend, dominant failure ROW_NOT_FOUND_AFTER_PREPARE (42/60). It has not been regenerated since — the benchmark page is stale.

2026-05 — Temporal migration, the abandoned v5/v6 policy engine, the overnight revert (157 PRs)

  • 2026-05-02 ADR-0016 renewal state-source registry. #576 saga-aware reset.
  • 2026-05-05/07 #635 auto-flip → RENEWED on the AppFolio portal-sign email (this email-triggered flip is later named as the cause of a real incident — see approved-to-signed.html: "Countersign truth comes from polling AppFolio's reports — never from email. We learned this on renewals the hard way."). #746 v2 promoted to canonical /leasing/renewals. #751/#753/#758 saga backfill + drift report + legacy fallback dropped.
  • 2026-05-09 #764 Sean's 5/7 UX review applied.
  • 2026-05-11 The overnight autonomous-renewals build: #837, #838 (4-step nudge ladder + PM email + AppFolio outcome handoff), #839 EventBridge + dogfood scope, #846, #852, #853 (drop step-4 PM reminder — "one PM email is enough"), #858 single-threshold auto-start (90 days default, 60d override), #859, #863–#874 (dry-run policy Lambda, analytics event types, MetricSnapshot writer, Outlook signals tool + HUMAN_REVIEW_PENDING + queue UI, policy-tuning admin page + weekly digest Lambda, v1 policy module with 10 gates).
  • 2026-05-12 #881 reverts the whole overnight build (#863–#874). Stated reasons: /simplify skipped on every PR, bot review missed on #869 and #874, internal jargon leaked into owner-facing UI ("PROVISIONAL METRIC MAPPING"). The revert PR was opened as DRAFT ("do NOT merge without explicit author go-ahead") and merged 2026-05-12T13:54Z. #893 then had to regenerate package-lock.json. Same day #865 rolled the nudge ladder out to all properties.
  • 2026-05-14 ADR-0025: self-hosted Temporal for renewal workflows.
  • 2026-05-16/17/19/20 The Temporal cutover: #988 RENEWAL_OUTREACH_KILL_SWITCH (emergency comms stop), #1005 renewal-nudge Lambda retired, #1065 renewal-outreach Lambda replaced by a daily Temporal Schedule, #1067 SendGrid bounces routed into the workflow, #1075 saga-writer kill switch + reconciler cron retired, #1080 migration script porting open sagas to workflows, #1187 legacy fireRenewalOutreach gated behind a Temporal allowlist, #1202 auto-start routed through startRenewalWorkflow, #1214 workflow-owned RVIEW projection cutover.
  • 2026-05-22 v6 eligibility engine #1307: 2 gates (balanceMonths, latePayments), binary AUTO_RENEW / HITL_REVIEW, no score. Its body records that the v5 engine POC branch fede/renewals-engine-poc was abandoned (260+ unrelated stale changes; it carried the 5-gate + score + BLOCKED_LEGAL machinery v6 deliberately cut). Plus #1317 AppFolio feature extractor, #1323/#1327 cohort walker in Phase-1 shadow mode, #1330 seed-policy script, #1334 policy editor wired to the v6 DDB entity, #1340 Clara decision column.
  • 2026-05-24 #1376 consolidates send switches into one fail-closed gate, ships disarmed. #1378 term permutations (6/12/MTM) in the E2E matrix.
  • 2026-05-26/27 Temporal namespace strategy pivots to one app-wide tenant-backend namespace (#1465 supersedes the per-domain rule). ADR-0036 #1558/#1565: email the offer + letter to no-phone tenants. ADR-0037 #1574: NTV-on-PM-capture chain.
  • 2026-05-26 (recorded later in the 09-04 quality audit as F2) the renewal letter was sent 4× to unit 118 in ~86 minutes.

2026-06 — holdover / month-to-month, ADR-0046, spine collapse (114 PRs)

  • 2026-06-03 ADR-0045 (#1852) unify Temporal namespaces onto propflow-prod. ADR-0046 (#1920) renewal expiry, holdover and execution notifications. #1801 capture positive renewal intent (incl. MTM) from email. #1896 fixes escalation wired to the wrong switch (per the 09-04 audit, that had been broken since launch).
  • 2026-06-04→09 The holdover build, ADR-0046 D1–D7: #1927 MTM holdover decision engine, #1929 HOLDOVER terminal saga state, #1942 holdover scan (lapsed-unsigned → MTM), #1947 AppfolioPMSWriter.convertToMonthToMonth, #1925 AppFolio lease provenance, #1922 email accounting on execution, #1923 MTM reminder warning, #1978 configure "signed" notification emails in the UI (D4), #2019 D1 office email + D2 re-offer lineage, #2020 decouple holdover arming from renewal outreach, #2021 flip the lease MTM flag during convert, #2023 pre-arm canary through the real worker, #2115 no-comms-to-declined guard at every send activity, #2119 D7 fresh-check for off-flow renewals, #2102 recover-wedged-renewal script (dry-run, gated).
  • The 07-31 incident doc dates the start of the ~7-week exposure window to 2026-06-09, when the lapsed warn SMS shipped.
  • 2026-06-10 #2129/#2137 formal-notice emails → firm-NTV path and AppFolio move-out draft, both flag-gated and disarmed. #2145 hardens signal extraction against 6 audit failure modes.
  • 2026-06-11 ADR-0055 (#2267) "renewal UI must reflect workflow state, not infer it" — per the 09-04 audit this fixed the "declined and stalled at once" phantom-status class that had affected 38% of history.
  • 2026-06-16→25 ADR-0035 spine collapse eats the renewal fields: #2427 re-key RenewalSaga tenantId→personId onto GSI4, #2517 retire renewal lifecycle timestamps, #2521 retire Tenant.renewalOutreachStage → Lease, #2523 retire Tenant.renewalSource, Phase F absorbs reasonForNonRenewal/unitNumber into TenantOccupancy.
  • 2026-06-22/23 ADR-0066 (#2570) collapse the two-job renewal pipeline. ADR-0067 (#2596) AppFolio session concurrency for parallel prep. #2578 auto-start skips already-resolved / offer-in-flight cycles.

2026-07 — the heaviest month (201 PRs), two incidents, ADR-0108

  • 2026-07-02 #2924 TRANSFERRED outcome (ADR-0076) — stop labelling unit transfers as Declined. #2851 simplified v2 list+detail behind a per-browser toggle.
  • 2026-07-05/06 ADR-0045 rewritten as a clean-cut namespace migration (#3085), cross-namespace renewal migration script (#3086), fail-closed TEMPORAL_NAMESPACE (#3084). #3148 renewal-prepare-retry auto-cure ships disarmed; #3257 (07-07) arms it always and retires the RENEWAL_PREPARE_RETRY_ARMED flag — a rare case of a gate actually being removed.
  • 2026-07-07 #3220 Renewal Failure Gauntlet prod-repro harness Phase 1; #3222 alert-only Temporal workflow-health sweep cron.
  • 2026-07-09→16 The board rewrite: #3327 ADR-0081 renewing into a different unit, #3346 guard renewal send against wrong-unit transfers, #3389 weekly owner report, #3435 MTM rows with in-flight offers, #3482 ADR-0085 shard the saga GSI4 hot partition, #3513/#3534 cohort-board prototype → live data, #3606 DataTable migration, #3677/#3695/#3816 Cohort Board v2 + Settled review ledger ("founders' #1 ask"), #3691 adopt the Yale 25 Station "% Renewed" formula, #3876 centralize the renewal-rate formula (responders-only, transfers = retention), #3878 the board replaces the classic table, #3902–#3935 six same-week board/detail revisions.
  • UI thrash, 2026-07-15/16: #3825 and #3835 redesigned the detail page; #3842 reverted both "— founder call"; #3935 then restored the original hero UI, #3926 restored the PM Approve control. Four days, two forward PRs, two restorations.
  • 2026-07-15 ADR-0091 (#3829) reconcile renewals against external PMS actions.
  • 2026-07-17 ADR-0100 (#3987) PM-decided month-to-month conversion. #4054 outreach exhaustion wraps up quietly instead of escalating to a PM — this is the decision the 09-04 audit blames for units 420/522 going silent. #3997 renewal D3 cut over to the executed-lease outbox (ADR-0098 Phase 5a).
  • 2026-07-18/19 #4110 renewal voice honors caller opt-out. #4167 renewal offer emails sent from the property mailbox via Graph. #4144 stops junk stuck-saga pages.
  • 2026-07-19/20 The "X-series": #4174–#4192 — eleven projection/terminal-state fixes in two days (already-MTM is terminal, held-for-review routes to Requires action, credit balances render as credit, offer summary shows the real sent offer, honest escalated-row copy).
  • INCIDENT 2026-07-21 — the false "ready for your signature" email. ADR-0104 (#4217, merged 07-20) went live via #4267, removing an allowlist. 44 minutes later the hourly pm-action-reminder-backstop emailed camelliaapts@jp-co.com: "A lease is ready for your signature — Ryan Davidson (Unit 218)", claiming the lease was signed on the tenant's end. Nothing was pending — it was Ryan's in-force July-1 transfer lease (308→218) whose lower-tier AppFolio mirror carries lastLeaseSignedAt: null forever. Evidence edl_a1f39f79, 2026-07-21T23:00:13Z. Blast radius 1 real email + 2 to a test address from orphaned harness fixtures. Root cause: the backstop approximated "awaiting countersign" as (no signature date + not archived + created within 30d), indistinguishable from an executed mirror lease — and the same file already refused to fabricate that signal for renewal_countersign. Fixed by #4279 (07-22): the lease leg becomes a structural no-op; the E2E harness, which only ever tested true positives, gained the incident-shaped negative regression, a single leaseSk() source of truth, a manifest moved out of the worktree (the post-merge reap had been destroying the prod cleanup ledger), and a residue exit gate. Prod remediation applied ~2026-07-22 01:30Z: 4 false cadences terminated, the backstop schedule paused, 2 orphaned PMREM leases deleted.
  • Same day, #4236 reverted #4217's merge — but that revert is process, not product: "ADR PRs are never merged by automation — the open PR is the discussion surface and Fede accepts the decision." #4297 restored lease_countersign reminders via a verified e-sign signal. #4263 made the PM "Pause" button actually pause outreach.
  • 2026-07-22 — ADR-0108, the one-spine refactor. #4322 the ADR; #4374 one decision spine (shared gate pipeline, per-caller dispositions); then thirteen fixes in one day: #4352 (approve starts the workflow before dispatching SQS — kills the unrecoverable sqs-orphan), #4353, #4354 (gate the autonomous path on the non-renewal stamp — the "NTV gate"), #4355 (one isDeliverableEmail; 'n/a' emails can no longer reach AppFolio), #4356 (atomic per-lease mint claim closes the scanner⇄PM double-mint race), #4357, #4358, #4359 (HELD tenants page the PM once — no more silent dashboard-only holds), #4371 retire drained patches + the RVIEW cutover gate, #4376/#4379/#4385.
  • 2026-07-24 #4492 stops the duplicate "ready for signature" email — AppFolio already sends that email itself when an offer enters awaiting-countersign, and the PM-action cadence was firing its own reminderIndex: 0 at the same moment. ADR-0104 §125 had predicted exactly this overlap; the dedupe mechanism existed and was never wired into the renewal bridge. Same PR adds weekend-skip (Sat/Sun nudges deferred to Monday 09:00 property-local, DST-aware).
  • 2026-07-25 DynamoDB TTL audit + receipt TTL floor (#4541). The 09-04 audit notes a workflow built to test real AppFolio automatically was never run once and was deleted 2026-07-25.
  • 2026-07-28/29 Quiet-hours-by-area-code shipped and was reverted the next day (#4843/#4844 reverted by #4866 — quiet hours are the property's local time).
  • 2026-07-29 The orphan-send class: #4842 fence orphan sends + stop anchoring renewal SMS to a closed saga, #4846 the send gate and the conversation anchor resolve ONE saga, #4865 gate the portal-link SMS on the anchor.
  • INCIDENT 2026-07-31 — notice tenants texted renewal quotes. At 08:30 MT the daily auto-start scan sent a lapsed-lease "renew now" SMS (renewal_expiry_nudge_sms, milestone lapsed) to two notice tenants at Camellia. Both occupancies carried reasonForNonRenewal: "Notice to Vacate filed", renewalState: "declined", unit status notice. For one, the PM had cancelled the AppFolio offers on 7/07 and set a move-out date of 7/31 — and Clara texted him a renewal quote that morning. Root cause: the lapsed/holdover branch continues before the ADR-0108 decision spine runs, so it enforced no gates at all (G8 declined-stamp, G9 suppression, G11 signer-email, G13 transfer); the pre-lapse 15-day warn band had the identical hole; sendScanMtmWarning never consulted ADR-0092 §5 suppression; and G8 read only the occupancy stamp, never the canonical lease's lastObservedNoticeAt / leaseMoveOutDate. Fixed same day by #5152, with a failing-first reproduction. A 30-day audit found no other wrong sends but 8 further gaps, 3 rated High, and 7 of 8 were still open at the time of the 09-04 write-up.
  • 2026-07-31, same day, second failure — a resident emailed asking to renew if rent came down ~13%; seven seconds later PropFlow told her PM the renewal was "ready for your signature — it just needs your signature to wrap up", while AppFolio said "Out For Signing — 0/1 Tenant(s) Signed." Her counter-offer surfaced nowhere a human would see. #5173 (07-31) made the countersign PM reminder require signature evidence. The 19-surface audit (#5175) found the same defect — copy asserting facts the system never verified — on 11 surfaces.
  • 2026-07-30/31 #5014 a thrown PM escalation email is a failure, not a success. #5192/#5205 renewal escalations lose their follow-up promises.

2026-08 — the honesty era; renewal feature work drops to 40 commits

  • 2026-08-01/02 ADR-0122 accepted; #5274 ships D1/D2 — tenantIntent carries provenance (pms_verified vs stated), a third kind negotiating is added, and the load-bearing OR that collapsed "said yes" with "signed" is split. countersignOnRenewIntent is deleted outright rather than left as a permanently-false parameter. Four PM-facing surfaces stop reading the phase name and start reading the evidence. The 09-04 audit calls ADR-0122 "the cleanest root-cause fix in the whole history."
  • 2026-08-05 #5438 re-send capability + preflight that refuses before the cancel. #5476 internal engineering review lane for renewal corrections.
  • 2026-08-07 #5563 extends two renewal patch retireBy dates because "grace elapsed at midnight, required checks red repo-wide" — the first of four such episodes.
  • 2026-08-09 #5592 one home for renewal facts: versioned RENEWAL_POLICY# rows, read-boundary substitution through loadRenewalPolicyView, a drift fence, and an offer that records the policy version that priced it. Ships dark: 0 of 17 properties enrolled, proven by scripts/renewal-shadow-diff.ts run twice against live prod records. No commit since mentions enrolling a property.
  • 2026-08-10→14 #5643 stop retrying PMS prepare once a lease left the pool. #5691 bound the renewal-decision read. #5741 executed-lease accounting notice waits for the countersign. #5754 waiting on a countersign is silent — no time-based alerts, daily long-tail polling. #5776 stop burning an AppFolio MFA code to learn "not countersigned yet".
  • 2026-08-15 #5800 retire six overdue renewal patched() guards (ADR-0108 D5).
  • 2026-08-17/18 — the guard deep dive. #5828 hold renewal reminders once a resident answered or a human owns it. #5830 scoped opt-out — a renewal-text "stop" no longer kills maintenance and email too. #5849 renewal_escalate opens a tracked matter at owner-armed properties, single engine.
  • 2026-08-20/24/28 #5487 derive prepareTimeout from the retry budget. #5807 ANTHROPIC_MAX_RETRIES on the renewal worker so a 429 burst backs off instead of dying. #6529 hard-exit watchdog + desiredCount=2 to stop a SIGKILL-every-deploy loop.
  • 2026-08-30/31 #6617 detail page onto the standard host; #6626 had to unbreak main the same day; #6624 fixed a nonexistent CSS token (push-main-red). #6720 gate the scan's lapsed/holdover branch on the eligibility verdict. #6721/#6722 wire the cohort walker to spawn renewalWorkflow and move holdover into the workflow (Phase 3, dark) — these two arms are deleted four days later. #6737 gate the cutover arms per property, not fleet-wide.

2026-09 — the Sept-3 incident, the architecture decision, then scope/permission work

  • 2026-09-02/03 — the ADR-0045 namespace cut. #6809/#6814/#6815/#6818/#6822 fixed pre-cut defects (the migration script could find 1 of the 9 live renewals; Step 4 never flipped TEMPORAL_ADDRESS; the contract was missing 4 live schedules; the gate could not see a schedule firing six hours off). Cut completed 2026-09-03T15:48:53Z; #6838 swept propflow-renewal-prod → propflow-prod; earliest decommission 2026-10-03.
  • INCIDENT 2026-09-03 — two starters. The cohort walker was switched on for Camellia while the original daily scan kept running; nothing coordinated them. From ~21:42 the night before to 08:00–08:02 Mountain: 11 renewal cases opened (the walker's 120-day window vs the daily job's 90-day rule), 5 of them ~a month early, 7 of 11 sent one text and one call, 10 PM pages went out twice (two starters, two separate "did we already page" lists), 3 failed at the AppFolio step with the real error swallowed by a generic wrapper, and 1 record (unit 417) went hollow when a retry wrote the wrong ID type into the tenant field. No tenant was contacted twice; no delinquent or on-hold tenant was contacted.
  • 2026-09-03 ~30 renewal PRs land in one day: #6849 auto-close renewals AppFolio already handled, #6850 route timeout/budget PMS failures to engineering not the PM, #6856 retry bootstrap carries the PMS tenant id, #6857 unify PM-escalation dedupe, #6871 attribute renewal touches to their lane, #6876 widen renewal-call identity (co-tenants + fuzzy names), #6893 scope the daily scan to armed properties, #6898 flip stable workflow identity (ADR-0108 D2) to armed by default, #6899 daily-backoff retry for INFRA prepare failures, #6902 the deletion (below), #6909 outbound calls speak the offer to whoever answers, #6913 Willows live-run harness, #6961 stop Willows test renewals touching JP's real AppFolio, #6962 renewal pages stop reading every property's renewals, #7016 a new start path can no longer skip the rulebook, #7028 prove a change against a whole month of scans.
  • 2026-09-04 #7038 a renewal keeps the offer document the PMS created, #7040 the proof run stops grading itself green on an empty run, #7067/#7068 archived sagas leaking into GSI4 open-saga reads, #7069 exclude Willows-harness sagas, #7079 undo the shared-read change to the renewals pages, #7094 an NTV date written in words counts, #7101 our own unanswered offer no longer blocks the MTM rollover, #7104 group the day-before heads-up into one email per property per working day, #7106 retire two more overdue patched() guards.
  • INCIDENT 2026-09-05/06 — three unwanted MTM conversions at Camellia. Per fede-priorities.html: the renewal fix that shipped 2026-09-04 without a gate converted three leases the next morning (units 420, 522, 504). 420 and 522 were what the July policy always intended; unit 504 had an open human case — the resident had said on paper they wanted to renew — and rolled anyway, because the fix looked at timing, not at whether a human owned the case. Recommendation on the page: fix only 504 by hand in AppFolio, leave the other two. The "human owns this" check on the conversion step is a separate fix, built dark.
  • 2026-09-05/06 #7133 the office MTM notice no longer promises a fresh renewal offer. #7137 (labelled [HOLD], hold-for-review, merged 2026-09-06T04:10Z) stops opening a new renewal offer after an MTM rollover — month-to-month is now the end state. #7145 exclude isTest bench properties from the lapsed-renewal alert. #7152 stop double-routing a renewal-thread maintenance mention.
  • 2026-09-10→17 Scope/portfolio work takes over: #7563 stuck-saga paging suppression (and #7066, still open), #7933 one portfolio-wide renewals call per account (dark, per-account arm), #7956 renewal birth paths declare the birth so the org stamp runs, #7988 the live dashboard's counts are the caller's not the deployment's, #8004 the renewals list asks whose building it is, #8263/#9718 the renewal batch stops texting from the global test line, #8550/#8572, #8803 renewal-auto-start.ts reads office hours through the walked chain, #9089 stop accepting two renewal settings fields nothing reads, #9165 the selection bounds the query not a post-filter, #9325 (merged 09-18) a per-property pmsWritesAllowedWhileTest opt-in so Willows can write to Test Property 45 again.
  • 2026-09-20/21 #10071/#10425/#10430/#10439 pin the renewal saga's scope and re-resolve it before first contact and before every tenant-facing and staff-facing send (p9-workflow-pins). #9838/#10431 retire pm_action_reminder_countersign_v1, #renewal_honor_pause_v1 and renewal_workflow_owned_dispatch_v1 (ADR-0108 D5). #10110/#10111/#10112 de-fork the renewal worker's activity registration after a push-main-red.
  • 2026-09-23 (today) Permission and honesty polish: #10993/#11001 approve/decision/outreach require property scope + leasing:edit, #11009 the auto-renewal switch requires settings edit, #10985 "Not on file" instead of "$0/mo", #11015 the renewals tile says "—" not 0 when its numbers did not load, #11089 an unread lease is Unknown not MTM, #11133 a scope_moved stop cancels the countersign reminder cadence, plus four latency PRs.

2. Decision register

Newest ruling wins. Source column: git/gh = PR body or commit; adr = /Users/federicochapa/.claude/propflowai/docs/adr/; doc = published page local copy in /Users/federicochapa/.claude/propflow-docs/artifacts/; mem = a session memory file under /Users/federicochapa/.claude/projects/*/memory/.

2.1 Policy and product rulings (Fede)

Date Decision Source
2026-04-16 Autonomous-renewal testing authorized, hard-scoped to Test Property (pmsExternalId == "45"); prepare AND send with no pre-ask; >1 month rent balance = manual review (the first, looser delinquency threshold) mem feedback_autonomous_renewals_auth.md, project_autonomous_renewals_scope.md
2026-04-22 "DO NOT USE THE NATIVE LEASE TEMPLATE. KEEP CAMELLIA TEMPLATE." — renewalPackage is always pdf_templates, never electronic_signature mem feedback_form_template_only.md
2026-04-22 "ALL TERMS MUST BE CREATED… never guess or pick… exactly what the policy says." Fail loudly on a missing/empty term list mem feedback_renewal_terms_exact_policy.md
2026-04-28 Letter filename is "First Last Lease Renewal Notice.pdf"; send order is SMS body first, then the PDF as MMS mem feedback_renewal_letter_filename.md
2026-04-28 First live send is SMS-only, voice deferred; "I'll choose, you only touch federico chapa" — tenant selection for a live test is Fede's mem project_overnight_2026-04-27_morning_brief.md
2026-05-06 A tenant with an active renewal gets a proactive opening line on any call mem feedback_proactive_first_message.md
2026-05-15 Production renewal escalation uses email + SMS, never Telegram (PMs missed the alerts) mem feedback_no_telegram_in_dashboards.md
2026-05-20 The renewal UI reads a DDB projection the workflow writes; it must never query Temporal Cloud directly. An earlier branch that did was abandoned as backwards mem feedback_temporal_ui_reads_not_temporal_cloud.md
2026-05-21 Camellia's PM-given rules: balance, not lateness, is the disqualifier; >2 months owed = human review; the PM's preferred hand-off channel is SMS, not email mem project_camellia_renewal_policy.md
2026-06-02 Escalation to a PM must never be gated by the send-arm. Disarmed means "hand it to a human," never silence mem project_renewal_pm_escalation_gap.md (locked)
2026-06-03 Holdover is fully autonomous; the reminder quotes the new month-to-month rate, never "+$100"; one re-offer then leave on MTM; accounting notification first, PDF later mem project_overnight_renewal_holdover_build.md, adr-0046
2026-06-04 "Reading A" — the DDB RenewalSaga stays as the durable read-model and the Temporal workflow is its only writer. Explicitly kills the saga-deletion plan adr-0047; planning renewal-saga-retirement-handoff.md marked "do not execute"
2026-06-09 Emailed notice to vacate is legally valid. "Do NOT re-argue the 'is email legally valid notice' angle — Fede decided it is." mem project_emailed_ntv_first_class.md
2026-06-09 No manual comms ever — warnings flow only through the armed pipeline mem project_camellia_july1_goal.md
2026-06-22 Do NOT add a legal/eviction gate to the decision engine. Legal signals are too flaky; late-payment count is the deliberate proxy mem propflow_renewal_three_surfaces.md
2026-06-24 Autonomous is the default and "it should be rare for exceptions." Per-decline PM email suppressed mem feedback_renewals_autonomous_default_hitl_rare.md (PR #357)
2026-06-24 On any decline, capture the reason AND always push the tenant to file a formal notice to vacate mem feedback_non_renewal_always_push_ntv.md
2026-07-07 "Just have it on", "no more flags" — the prepare-retry arm is deleted, retry is unconditional mem project_renewal_retry_armed.md (PR #3257)
2026-07-13 % Renewed = (Renewed + Transfers) ÷ Expired, not renewed-over-decided mem project_yale_renewed_pct_formula.md (PR #3691)
2026-07-15 The two board KPIs are "Handled" and "Handled autonomously" — replacing the RENT IMPACT / AVG INCREASE tiles that shipped without being asked for mem project_renewals_settled_kpi_definition.md
2026-07-15 Board rules: renewal % counts responders only; month-to-month gets its own yellow designation and is never grouped under "Did Not Renew"; "Handled in AppFolio" is not a status label mem propflow_renewals_board_standup_feedback_2026_07_15.md
2026-07-15 The lease-execution accounting notice is always on for every lease kind, reversing the transfers-only design. "Don't reintroduce gating flags." A transfer is never counted as renewed or declined mem project_transfer_notices_always_on.md, adr-0076
2026-07-16 An outcome is only EXECUTED or HOLDOVER — "handled in AppFolio" is never a valid outcome state mem propflow_external_wrapup_architecture.md, adr-0091
2026-07-20 "Legal stuff needs an ADR first — very delicate." The eviction/legal-hold gate is backlogged pending ADR approval, not built mem propflow_taylor_ballard_eviction_context.md
2026-07-23 "its already on" — autonomousRenewalEnabled=TRUE at Camellia is intentional. Policy v2 values locked: maxBalanceMonths 2.0, maxLatePayments 6, lateGraceDays 25 (v1→v2 dry run moved auto-approval 59.3% → 92.0%) mem project_camellia_policy_v2_state.md
2026-08-08/09 "the most important thing is to not break anything in production… 0 bugs in months after dozens of iterations." Any renewal change needs a byte-identical shadow diff against real prod data and ships dark mem feedback_renewals_zero_regression.md
2026-08-13/14 "it should never give up… it should wait until it's available." Notices poll forever (15m → hourly → 6-hourly → daily); the AGED_OUT wall is deleted mem feedback_lease_notices_never_age_out.md (PRs #5741, #5754); supersedes adr-0098 §3.1
2026-08-14 "leases can take weeks or months to sign. that's normal." 24-hour paging removed; alerts go daily after week one mem feedback_lease_countersign_timeline_normal.md
2026-08-18 Guard deep dive, Decision 6: Option A doc renewal-guard-deep-dive-2026-08-18.html
2026-09-03 Option B — "one starter, one owner, fix in place." See section 5 doc renewals-architecture-decision-2026-09-03.html; PR #6902
2026-09-03 Clara follows the PMS's own signal with no human-review queue when AppFolio shows a renewal already handled mem feedback_follow_pms_signal_no_confirm.md
2026-09-03 Outbound renewal calls state the offer to whoever answers; no identity interrogation. Inbound unknown callers still get the name+unit check mem feedback_outbound_renewal_call_no_identity_gate.md (PR #6909)
2026-09-03 Reminder cadence: "reminder 3 times like we do today with leases. business days only. once per day max." doc renewals-architecture-decision-2026-09-03.html
2026-09-04 "the Willows is our test property" — reverts #6961's blanket block on test-property PMS writes git (#6961 revert; formalised by #9325, merged 2026-09-18)
2026-09-04 A free month is a manual credit, never a lease term mem feedback_free_month_not_in_lease.md
2026-09-04 Renewals lab — reproduce every renewal bug since launch in a replay before building more. Most in-flight feature builds paused. Once a human takes over in AppFolio, Clara stops chasing mem project_renewals_lab_program.md
2026-09-05 "i dont want to offer anything, just roll mtm." / "i dont want it." Month-to-month is the end state; no re-offer next cycle mem project_mtm_rollover_no_reoffer.md; PR #7137; supersedes adr-0046 D2
2026-09-05 "Silence is not approval." A behavior fix ships behind a per-property gate, off by default; an unanswered decision is a hold, not a green light mem feedback_silence_is_not_approval.md (post-#7101 incident)
2026-09-12 "Everything in the PM flow is PM approved is not true, we have autonomous lease sending" — renewals ARE autonomous at Camellia; Willows and Yale are off mem project_renewal_never_autonomous.md

Standing rules with no single date: never expose renewal-policy knowledge fields (rent strategy, max increase, MTM premium, delinquency threshold) to tenants (mem feedback_renewal_policies_tenant_visibility.md); the offer SMS and the letter PDF are one atomic send, never decoupled (mem feedback_renewal_sms_pdf_atomic.md); do not build browser automation for notice-to-vacate creation — residents self-serve in the AppFolio portal (mem propflow_ntv_self_serve.md); opt-out preferences do not apply to the resident portal as a required signing channel (mem propflow_portal_optout_policy.md); do not propose another mass-text campaign to collect tenant emails — it already failed (mem propflow_add_contact_rows_reality.md); do not add an intentToVacateCount KPI — it is a soft prediction (mem propflow_intent_to_vacate.md).

2.2 Architecture decisions (ADRs)

Renewal ADRs in creation order, with the one-line call. Full table in the subagent detail: 0016 reset-source registry (2026-05-01) · 0025 adopt Temporal (05-14) · 0036 email the offer to no-phone tenants (05-27) · 0037 moveOutDate transition is the canonical formal-NTV trigger (05-27) · 0045 one Temporal namespace propflow-prod (06-02, rewritten 07-05) · 0046 month-to-month holdover + execution notices (06-03) · 0047 keep the DDB saga as the read-model (06-04) · 0055 the UI reflects workflow state, never infers it (06-11) · 0066 collapse the two-job pipeline so the verdict actually gates the send (06-22) · 0067 serialize AppFolio browser jobs per account (06-24) · 0074 detect transfers vs move-outs (07-01) · 0076 transfers: label correctly, change nothing else (07-02) · 0081 guard the wrong-unit offer (07-08) · 0085 shard the saga GSI4 hot partition (07-10) · 0091 the PMS is the system of record; reconcile against it (07-15) · 0098 executed-lease notice pipeline (07-16) · 0100 PM-decided month-to-month (07-17) · 0108 one spine, workflow-owned dispatch, stable identity (07-22, Accepted 07-23) · 0118 internal transfers get their own bucket (07-28) · 0122 evidence-gated notifications (08-02).

2.3 Contradictions to flag

  1. Holdover ownership — a live documentation contradiction. The published priorities page (/Users/federicochapa/.claude/propflow-docs/artifacts/fede-priorities.html) says "Holdover handling moved inside the renewal workflow… Fede (Option B) Sept 4." PR #6902 (merged 2026-09-03) did the opposite: it deleted workflow-owned holdover, leaving the daily scanner as the sole, unconditional owner. The code is the truth; the page is wrong.
  2. ADR-0046 D2 vs the 2026-09-05 ruling. The ADR body still describes re-offering next cycle. A banner later in the same file kills it. Read to the banner or you will build the wrong thing.
  3. ADR-0074 C1 vs ADR-0076. 0076 was written to reverse 0074's turnover-suppression call after a live incident, but 0074 is still marked "Proposed" and its C1 text is unmarked.
  4. ADR-0091 vs its own three addenda. Successive rulings (07-17 twice, 09-03) stripped nearly every human-confirm gate from the original Decision 3, which now reads as obsolete on its own.
  5. Two renewal-rate formulas coexist on purpose. The Yale-locked formula (Renewed + Transfers) ÷ Expired (2026-07-13) and the dashboard's strict signedRenewed ÷ (signedRenewed + declined + intentToVacate) are both live; the memory notes this is deliberate, and that the two surfaces can still diverge on edge cases.
  6. Delinquency threshold drifted three times — >1 month (2026-04-16 scope) → >2 months (2026-05-21 PM policy) → 2.0 months + 6 late payments + 25-day grace (Policy v2, locked 2026-07-23). Only the last is current.
  7. Twenty-two duplicate ADR numbers exist repo-wide, including 0104 (a floor-plan-media ADR is filed under the same number as PM action reminders) and 0055.

3. Sessions that worked on renewals, and what each left unfinished

Fourteen session directories under /Users/federicochapa/.claude/projects/ were read. Twelve hold substantive renewal memories; agents-010, -federicochapa and -federicoc hold none beyond aspirational product specs.

Session Period and focus Left unfinished, per its own notes
agents-000 Jun–Jul 2026 — saga/Temporal state, alert noise, escalation gap, KPI definitions The June legacy-surface audit marked dead fields and draft-renewal-outreach.ts "safe to delete" but nothing was deleted and the docs were never corrected; ~15 stuck sagas and the ~11 Camellia + ~4 migrated orphans left as a manual drain (Trello sYheYp7B); GSI4 not built at the time; the prepare-retry sweep still never classifies saga.lastError before re-dispatch, so a permanent 422 burns all three retries; the corrected-KPI board rebuild never done
agents-001 Jun 2026 — holdover/MTM build, durable arms, Temporal runtime, nudge ladder Scan-reconciler detect+alert (PR B) designed, never built; TURNOVER_AUTONOMOUS_SENDING left env-only with no durable arm; PR #2028 (in-flight renewal not stopped) held; converting to holdover still does not flip AppFolio's lease-level month-to-month flag — deferred; fetchExecutedLeasePdf and cancelRenewal left as stubs, and cancelRenewal later reported success without cancelling, leaving three stale AppFolio offers voided by hand; PR #892 left in draft; PR I (saga-writer deletion, ~30 files) not done; Camellia's lease-execution notice gate deliberately left OFF
agents-002 Jun–Jul 2026 — email ingestion, NTV chain, external reconcile, v2 UI toggle The webhook dedup race (~3x processing) found and never fixed; PR #5175 (ADR-0122) held for Fede, never auto-merged; 11 of 19 PM surfaces with claim/evidence mismatches found, only 4 filed; ADR-0091 reviewer nits deferred (subDays floor, observe-default, Zod validation); the v2-UI default-flip decision was deferred pending a week of team use and never made; 37 confirmed bugs from the 166-renewal sweep captured as cards with no fixes
agents-003 Apr–Aug 2026 — the largest renewal session: cutover, UI overhaul, incidents Phases 5–6 of the saga migration halted mid-flight with dual-writes live and ~24 legacy read sites still reading; PR #1156 (12k LOC saga deletion) left a draft with 5 bot blockers; the SQS-bypass lockdown (IAM, workflow guard, callsite refactor) never done — only logging landed; the T-Mobile toll-free block left with open carrier action items; detail-page simplification, Gera's sign-off and v1-UI deletion never finished; a known production gap where channels silently default to ['sms']; /api/reset-renewal still only cancels the offer, never voids the lease document
agents-004 Apr–Jul 2026 — policy v2, ADR-0108 program, rate surfaces, transfer notices ADR-0108's old mechanism (renewal-retry-budget.ts, the pmsFailed signal, the retry-cron step) not removed; two patches carried 07-25/08-08 retire dates; P18 non-AppFolio silent-SENT accepted as a known defect; the change-term overnight plan blocked at phases 2–8; the L2 term-dropdown automation left at zero successful runs; the send-path idempotency bug noted as remaining; walk-in move-ins with no application still get no notice; the renewals page and dashboard can still diverge
agents-005 Jun–Aug 2026 — bug reproduction, login mutex incident, nudge-after-answer B4 (saga/activity-log drift untracked) and B8 (the letter-MMS leg has no already-sent-this-cycle guard and can resend every run) left open; follow-ups D1–D6 from the login-mutex incident open, including MTM-warning timing
agents-006 Jun + Sep 2026 — Temporal wedge, isolation audit Incident PRs #2099, #2101–2103, #2107 noted as still held
agents-007 Sep 2026 — the Option B cutover and the lab program PR #6913 (full Willows-path harness) explicitly needs Fede's manual merge; the Avery incident left a residual with no staleness check on the eligibility decision, flagged as needing a policy call; three feature builds paused by the lab program (no-email SMS ask, unreachable-resident fallback, AppFolio-signal watcher)
agents-008 May–Jul 2026 — board design, MTM gaps, eviction context, three-surfaces audit The legal-hold gate backlogged pending an ADR; dead create_ntv scaffolding from PR #296 flagged for removal, still in place; the Temporal cron sends at 09:00 UTC = 3 AM Denver, inside quiet hours — found, never fixed; ElevenLabs caller ID never provisioned, so voice never worked on the first autonomous sends (Task #29); the detail page still ignores ADR-0091 external-resolution events; an already-MTM lease with a live sent offer is still mislabelled; signal-attribution bugs (wrong tenant, wrong unit) unfixed; the structural channel-derivation fix designed, not built; the ADR-0046 canary ("green on test property before arming") was never actually run before holdover went live
agents-009 Aug 2026 — zero-regression bar, reply architecture The dual-path reply system (legacy classifier vs Clara's 5-tool agent) never cut over: the allowlist was never widened to everyone and the classifier file was never deleted
agents-011 Sep 2026 — worker deploy, send-gate arm state, detail-page latency Captured within the 005/006/007 material; nothing distinct left open

4. Skeletons — built then turned off, abandoned, or carrying a retirement date

4.1 Deleted outright, not merely disabled

  • Cohort-walker spawn wiring and workflow-owned holdover — PR #6902, 2026-09-03. The walker's ability to start a renewal was deleted rather than switched off, on the stated reasoning that "a switched-off-but-present starter is exactly the kind of thing that gets accidentally re-armed." The walker survives look-only, permanently Phase-1 shadow.
  • transitionSaga + state-machine.ts — PR #1250, 2026-05-20, ~12,834 lines removed.
  • Renewal-nudge Lambda and its scheduler — PR #1005, 2026-05-18.
  • The old saga-reconciler cron — PR #1075, 2026-05-18, with no replacement, which is what orphaned the ~15 sagas.
  • RENEWAL_PREPARE_RETRY_ARMED — deleted 2026-07-07, PR #3257, retry made unconditional.
  • RENEWAL_AUTO_START_ENABLED — env fallback retired 2026-07-07, replaced by the durable CONFIG/RENEWAL_ARMS.autoStart row. arm-store.test.ts and renewal-auto-start.test.ts pin that the env var can no longer arm anything.
  • TEMPORAL_SHADOW_ENABLED / TEMPORAL_SHADOW_PROPERTY_ALLOWLIST (2026-07-26), RENEWAL_COHORT_WALKER_ENABLED (2026-07-07), SCHEDULING_ENGAGED_AUTONOMOUS_SENDING (2026-07-09), and the original 0025 gating trio TEMPORAL_ACTIVITIES_EFFECT_MODE / TEMPORAL_LIVE_PROPERTY_ALLOWLIST / RENEWAL_OUTREACH_KILL_SWITCH (2026-05-24).
  • countersignOnRenewIntent — deleted by ADR-0122.

4.2 Reverted

  • PR #3060 co-signer sync, shipped 2026-07-05 and reverted by #3068 the same window for minting phantom Person records; replaced by the narrower signer_missing_email gate (#3062).
  • PR #6961 blocked Willows test renewals from touching JP's real AppFolio; reverted on Fede's "the Willows is our test property," then re-solved properly by a per-property pmsWritesAllowedWhileTest opt-in (#9325, merged 2026-09-18).
  • PR #7079 undid the shared-read change to the renewals pages (2026-09-04).

4.3 Abandoned plans still sitting in the repo

  • docs/planning/renewal-saga-retirement-handoff.md — the whole saga-deletion plan, superseded by ADR-0047 two days after it was written; its blueprint branch fede/phase-c-saga-deletion is 693 commits stale. The doc now says "do not execute it," but a reader who finds the plan first will build the wrong system.
  • docs/planning/renewal-saga-gsi-hot-partition.md — ADR-0085's 16-way shard, "Owner: TBD… not yet built."
  • docs/planning/ntv-holdover-firm-routing.md (2026-08-31) — closes with an open decision explicitly held for Fede: "this is a decision for Fede, not for me — raised rather than chosen."
  • The branch that made the UI query Temporal Cloud directly (fede/ui-temporal-queries) — abandoned as architecturally backwards.

4.4 Arms and gates live today

/Users/federicochapa/.claude/propflowai/src/lib/domain/admin/arms-registry.ts carries three renewal arms. Two are two-factor: a global env token and a per-property flag, both required, fail-closed.

Arm Env token Property flag Gates
renewal.sending RENEWAL_AUTONOMOUS_SENDING autonomousRenewalEnabled Clara sending renewal offers and nudges to real tenants
renewal.holdover RENEWAL_HOLDOVER_CONVERSION autonomousHoldoverEnabled The billing mutation when a lease lapses unsigned, and the reminder copy that promises it
renewal.autoStart durable DDB row CONFIG/RENEWAL_ARMS.autoStart — The daily 90-day scanner

Also live: EMAIL_NTV_FIRM_ROUTING_ENABLED (armed), PROPFLOW_ROSTER_GATE (armed 2026-09-12), RENEWAL_TASK_QUEUE_OVERRIDE (harness only), TEMPORAL_NONDETERMINISM_METRICS_ALERT. Arm state moved from env vars to durable DDB on 2026-06-09 (PRs #2069, #2088) precisely because every worker deploy had been silently resetting arms to disarmed.

Current per-property state, per the memories: Camellia sending and holdover ON (confirmed intentional 2026-07-23); Willows and Yale OFF; Camellia's lease-execution notice gate OFF.

4.5 Temporal patches carrying a retirement date

All 45 retireBy markers live in one file: /Users/federicochapa/.claude/propflowai/src/__tests__/temporal-patched-ledger.drift.test.ts. This is ADR-0108 D5 in code — every live patched() guard in the renewal workflow must carry a retireBy (landed + 60 days); the drift test warns once it passes and fails once retireBy + 14 days has passed.

Live renewal patches and their dates: renewal_infra_classifier_budget_v1 (landed 2026-09-03, retire 2026-11-02) · countersign_reminder_requires_signature_v1 (2026-07-31 → 2026-09-29) · renewal_commitment_stated_phase_v1 and renewal_negotiating_intent_v1 (2026-08-01 → 2026-09-30) · renewal_scope_pin_v1 (retire 2026-11-19) · renewal_scope_pin_v2/v3/v4 (retire 2026-11-20) · outreach_min_gap_v1 and outreach_progress_projection_v1.

Nineteen are already deprecated, including phase0_load_saga_state_v1, letter_mms_in_workflow_v1 and outreach_parallel_v2 (landed 2026-05-16/17, retired 2026-07-23); a six-patch batch retired 2026-08-15; defer_outreach_to_send_window_v1 and renewal_transient_prepare_resilience_v1 (2026-09-05); renewal_holdover_in_workflow_v1 (2026-09-03); renewal_workflow_owned_dispatch_v1 (2026-09-21). The regime is visibly working: retirement PRs landed 2026-09-04 (#7106), 09-14 (#8550), 09-15 (#8608), 09-20 (#9838) and 09-21 (#10431), several of them explicitly racing a ledger freeze date.

4.6 Known residuals deliberately accepted

The 09:00 UTC cron that fires at 3 AM Denver, inside quiet hours. P18 non-AppFolio silent-SENT. The saga/activity-log drift with no tracking (B4). The letter-MMS leg with no already-sent-this-cycle guard (B8). The two divergent renewal-rate formulas. Walk-in move-ins with no application getting no notice. Holdover changing the rent without flipping AppFolio's lease-level month-to-month flag. No eviction or legal-hold gate at all, by explicit ruling.


5. What the 2026-09-03 architecture decision decided, and what shipped

Source: /Users/federicochapa/.claude/propflow-docs/artifacts/renewals-architecture-decision-2026-09-03.html.

5.1 The choice

Four options were put up. Option B was chosen: "one starter, one owner, fix in place." Option A was patch-only; Option C was to finish the cohort-walker cutover; Option D, a full rewrite, was rejected on volume — 133 lifetime renewals did not justify it, with a note to revisit if volume grows tenfold. The problem being solved was that two half-built systems could each start a renewal, and the newer one (the cohort walker) had run one live morning and miscounted five tenants about a month early, because it used a 120-day window where the scanner used 90.

Five sub-decisions:

  • Q2 — delete the walker's start capability outright, rather than switch it off, because "a switched-off-but-present starter is exactly the kind of thing that gets accidentally re-armed."
  • Q3 — make stable renewal addressing default-on, the one deliberate exception to off-by-default.
  • Q4 — let the five early offers stand and pause their ladder rather than claw them back.
  • Q5 — yes: text and call for a missing email before paging a PM.
  • Cadence, in Fede's words: "reminder 3 times like we do today with leases. business days only. once per day max."

5.2 What shipped

Essentially all of it, the same day or within 48 hours.

Decision Shipped as Date
Option B, delete the second starter #6902 "Delete cohort-walker spawn wiring + workflow-owned holdover (Option B: one starter, one owner)" 2026-09-03
Q3 stable identity default-on #6898 "Flip renewal stable workflow identity (ADR-0108 D2) to armed by default" 2026-09-03
Only armed properties are scanned #6893 scope the daily auto-start scan reads to armed properties 2026-09-03
No new bypass of the rulebook #7016 "a new way to start one can no longer skip the rulebook" 2026-09-03
Q5, reach the tenant before paging a PM #6967 the renewal escalation fix actually reaches the phone; #6909 outbound calls speak the offer to whoever answers; #6876 widened identity check (co-tenants, fuzzy names) 2026-09-03
Infra-failure retry discipline #6899 daily-backoff retry for INFRA prepare failures + idempotent-offer guard 2026-09-03
Proof against a month of scans, not one decision #7028; #7040 stops the proof run grading itself green on an empty run 2026-09-03/04
Same-day walker-postmortem fixes #6857, #6850, #6858, #6849, #6853 2026-09-03

Follow-through in the fortnight after: #7026/#7035/#7038 (record the PMS reference, save the right AppFolio id, keep the offer document), #7067/#7068/#7069 (archived and harness sagas leaking into the open-saga reads), #7104 (group the day-before heads-up into one email per property per working day), #7106 (retire two overdue patches).

5.3 What did not go to plan

  • #6913, the full Willows live-run harness for the real start path, was flagged as needing Fede's manual merge — the one piece of the decision left hanging.
  • #6961, which stopped Willows test renewals touching JP's real AppFolio, was reverted the next day on "the Willows is our test property," and only properly re-solved two weeks later by #9325.
  • #7101 (2026-09-04) fixed the rollover so our own unanswered offer no longer blocks it — and shipped with no gate, converting three units the next morning instead of the intended one. That is what produced both the "silence is not approval" ruling and the "i dont want to offer anything, just roll mtm" ruling, and #7137 the day after.
  • The published priorities page still describes Option B as having moved holdover into the workflow. #6902 moved it out. (Inference: the page was written from the pre-decision option list and never corrected.)
Temporal health check

Source: scratchpad/report-temporal.md

Temporal health check — renewals (READ-ONLY)

Date of check: 2026-09-23 (queries run ~23:10–23:40 UTC) Namespace: propflow-prod.hmqma @ propflow-prod.hmqma.tmprl.cloud:7233 CLI: temporal 1.7.0 (Server 1.31.0, UI 2.49.1), at /opt/homebrew/bin/temporal Auth: Keychain propflow-temporal-api-key-prod (404-char API key). TEMPORAL_ADDRESS / TEMPORAL_NAMESPACE were set explicitly per instructions; the Keychain copies are stale. Window: StartTime BETWEEN '2026-06-25T00:00:00Z' AND '2026-09-24T00:00:00Z' Scope: list / count / describe / show only. No terminate, signal, reset, cancel, or repo edit.

Hard caveat on the 90-day window. The old namespace propflow-renewal-prod.hmqma is gone — nslookup propflow-renewal-prod.hmqma.tmprl.cloud returns NXDOMAIN, and every CLI call against it fails with failed reaching server: no children to pick from. So executions before the 2026-09-03 cut are not queryable at all; the numbers below cover roughly 2026-09-03 → 2026-09-23 (20 days), not 90. Anything claiming a full-quarter renewal trend would be fabricated. The cut is described in /Users/federicochapa/.claude/propflowai/docs/runbooks/temporal-namespace-cut.md (ADR-0045).


1. Inventory (from the repo)

Repo root: /Users/federicochapa/.claude/propflowai

Renewal task queue

  • propflow-renewal — declared inline in src/lib/temporal/client.ts:135 (TASK_QUEUE) and src/lib/temporal/executed-lease-fetch-client.ts:36; overridable only by RENEWAL_TASK_QUEUE_OVERRIDE (test-harness only, must never be set on the prod task def). Confirmed live via temporal task-queue describe, and it is the queue on all 16 running renewalWorkflow, all 19 running executedLeaseFetchWorkflow, and both renewal schedules.
  • Sibling queues: propflow-leasing, propflow-leasing-eval, propflow-tour, propflow-turnover, propflow-maintenance, propflow-maintenance-comms, propflow-vendor-chase, propflow-collections-chase, propflow-escalation, propflow-inbound-package, propflow-mass-comms, propflow-pms-sync-eval, propflow-review, propflow-onsite-pricing, propflow-conversation-topics, propflow-conversation-rationale, propflow-conversation-grade, propflow-promise.
  • src/lib/temporal/connection.ts holds no queue names — only TEMPORAL_ADDRESS / TEMPORAL_NAMESPACE (namespace is fail-closed, no default, per ADR-0045) and auth selection (API key → mTLS → localhost).

Where the renewal worker actually runs in production

Not src/lib/temporal/workers/renewal-worker.ts — that is the local-dev runner. The deployed entrypoint is:

  • services/renewal-worker/index.ts (449 lines) — one process, bundleWorkflowCode({ workflowsPath: require.resolve('src/lib/temporal/workflows') }) built once and shared across 17 Worker.create calls (renewal, tour, onsite-pricing, turnover, maintenance, maintenance-comms, vendor-chase, collections-chase, mass-comms, conversation-topics, conversation-rationale, conversation-grade, promise, tenant-confirmation-review, leasing, leasing-eval, inbound-package, escalation). Identity = ${taskQueue}-${buildId}-${ecsTaskId}, which is exactly the shape seen in the live poller list (§6). Per-worker concurrency default 5/5. Has a hard-exit watchdog added 2026-08-29 after propflow-promise hung in DRAINING and every restart ended in SIGKILL (exit 137).
  • services/renewal-worker/Dockerfile (node:24-slim, two-stage, runs tsx), services/renewal-worker/healthcheck.js.
  • infra/temporal-worker/task-definition.template.json — family propflow-renewal-worker-__ENV__, cpu 512, memory 3072, ECR 194925971150.dkr.ecr.us-east-1.amazonaws.com/propflow-renewal-worker, stopTimeout: 120, SENTRY_DSN + SENTRY_ENVIRONMENT + RENEWAL_AUTONOMOUS_SENDING + TEMPORAL_ADDRESS/NAMESPACE.
  • infra/temporal-worker/{provision.sh,bootstrap-secrets.sh,preflight-secrets.py,ecr-lifecycle-policy.json,iam-*.json} — shell/CLI provisioning. No terraform, CDK, fly.io, Railway, systemd or launchd anywhere.
  • Deploy: .github/workflows/deploy-renewal-worker.yml — triggered by workflow_run on CI-completed/main (only when conclusion == success && event == push) plus workflow_dispatch; no cron. ECS cluster propflow-renewal-worker-prod, service renewal-worker, desiredCount 2 in prod (1 stage), minimumHealthyPercent=100 / maximumPercent=200, circuit breaker with rollback, --force-new-deployment, then aws ecs wait services-stable. Redundant builds skipped by a source-tree-hash ECR tag (deployed-<env>-<srchash>).
  • Activities: src/lib/temporal/workers/renewal-worker-activities.ts — single source of truth, spreading 8 modules (renewal, renewal-auto-start, renewal-cohort-walker, renewal-scope → pinRenewalScopeActivity / checkpointRenewalScopeActivity, pm-action-reminder-renewal-bridge, executed-lease-fetch, conversation-review, grader-coverage). Extracted 2026-09-20 precisely because the dev runner and the Fargate entrypoint each hand-maintained a copy; parity is now pinned by src/__tests__/renewal-worker-bridge-registration.test.ts. (This is the fix for the §4 failures.)

Workflow types addressed at propflow-renewal

renewalWorkflow (RENEWAL_WORKFLOW_TYPE, renewal-workflow.ts:464), renewalAutoStartScanWorkflow, walkRenewalCohortWorkflow, conversationReviewScanWorkflow, graderCoverageScanWorkflow, executedLeaseFetchWorkflow. The whole 33-type barrel src/lib/temporal/workflows/index.ts is bundled for every worker; routing is by (workflowType, taskQueue), there is no per-queue subsetting.

Workflow function File
renewalWorkflow src/lib/temporal/workflows/renewal-workflow.ts (3,743 lines)
walkRenewalCohortWorkflow cohort-walker-workflow.ts
renewalAutoStartScanWorkflow renewal-auto-start-workflow.ts
executedLeaseFetchWorkflow executed-lease-fetch-workflow.ts
pmActionReminderWorkflow pm-action-reminder-workflow.ts (+ pm-action-reminder-logic.ts)
walkPmActionReminderBackstopWorkflow pm-action-reminder-backstop-workflow.ts
conversationReviewScanWorkflow conversation-review-workflow.ts
graderCoverageScanWorkflow grader-coverage-workflow.ts

Schedule definitions in repo

Specs live in the client modules (src/lib/temporal/client.ts, pm-action-reminder-backstop-client.ts, tour-client.ts, turnover-client.ts, onsite-pricing-client.ts, …); scripts/temporal/upsert-*-schedule.ts are thin CLI wrappers (--cron, --tz, --armed/--paused, --note). Contract source of truth: scripts/temporal/adr0045-namespace-contract.ts (SCHEDULES, cron/tz read off the live source namespace on 2026-09-02, not code defaults; SEARCH_ATTRIBUTES = renewalId, leaseId, propertyId, currentStage, conversationId, workOrderId, traceId, all Keyword).

Nightly renewal harness canary

.github/workflows/nightly-renewal-harness.yml — a **GitHub Actions cron (0 9 *, 09:00 UTC), not a Temporal Schedule.** No-real-comms tiers only (Tier 0, no-comms Tier 1, Tier 4), tenant tenant-gauntlet-appfolio-45, owned Twilio number +18334085783; posts failures to Slack #alerts (C0BGTNSP04C). The heavier run_willows_live_run (a real renewalWorkflow on an isolated queue) is workflow_dispatch-only and default off. Also: .github/workflows/renewal-replay.yml (PR-triggered, path-filtered, hermetic 30-simulated-day replay), renewal-evals.yml, renewal-evals-sms.yml, renewal-evals-scoping.yml, and schedule-drift.yml (30 7 *) — which reconciles EventBridge + vercel.json crons against config/automations.toml but does not cover Temporal Schedules at all.


2. Execution counts (2026-06-25 → 2026-09-24, effectively 2026-09-03 →)

Namespace-wide, grouped by status

temporal workflow count --query "StartTime BETWEEN ... GROUP BY ExecutionStatus" → Total 59,677

Status Count
Completed 39,178
ContinuedAsNew 19,718
Running 385
Terminated 319
TimedOut 50
Failed 25
Canceled 2

Renewal-related, per type

GROUP BY is only supported on ExecutionStatus in Temporal Cloud (GROUP BY WorkflowType is rejected: "'GROUP BY' clause is not supported for search attribute WorkflowType"), so each type was counted individually.

Workflow type Total Completed Running Terminated Failed TimedOut ContinuedAsNew
renewalWorkflow 360 210 16 132 2 0 0
walkRenewalCohortWorkflow 20 20 0 0 0 0 0
renewalAutoStartScanWorkflow 22 22 0 0 0 0 0
executedLeaseFetchWorkflow 22 3 19 0 0 0 0
pmActionReminderWorkflow 14,375 14,302 0 73 0 0 0
walkPmActionReminderBackstopWorkflow 488 488 0 0 0 0 0
tenantConfirmationReviewWorkflow 4 4 0 0 0 0 0
promiseWorkflow 1,758 1,758 0 0 0 0 0

walkRenewalCohortWorkflow = 20 and renewalAutoStartScanWorkflow = 22 are consistent with ~20 daily fires since the 2026-09-03 cut, i.e. no missed days — not a 90-day gap.

Zero ContinuedAsNew anywhere in the renewal family. Verified explicitly for renewalWorkflow, executedLeaseFetchWorkflow, prospectOutreachWorkflow, tourWorkflow, turnoverWorkflow, collectionsChaseWorkflow — all Total: 0. All 19,718 ContinuedAsNew executions are conversationGraderWorkflow.


3. Currently Running renewal workflows

16 running renewalWorkflow. Oldest start 2026-09-03T15:18:05Z; 15 of the 16 started in a 17-second burst at 2026-09-03T15:18:05 → 15:18:22. That burst is the ADR-0045 migration recreating the cohort on the new namespace, so "oldest start time" is a migration artifact, not real age — true age of the underlying renewals predates it and is unrecoverable (namespace deleted). The 16th started 2026-09-18T14:30:06Z.

Workflow ids encode the lease end date, and every one is in the future — 2026-10-31 (5), 2026-11-30 (7), 2026-12-31 (4). Nothing is running past its lease end.

Sample of 6 described:

Workflow id Start History length History bytes Pending activities Pending children
renewal-lease_fa611644-1d9e-4a45-9deb-4f661a92b686-2026-12-31 2026-09-03T15:18:05 104 20,567 0 0
renewal-lease_98be4668-9559-4a4a-a8c9-f4b98666517f-2026-10-31 2026-09-03T15:18:09 104 20,410 0 0
renewal-lease_d6d15602-ba2e-4e3e-abfb-2c8a01f6d8b9-2026-10-31 2026-09-03T15:18:12 122 24,870 0 0
renewal-lease_626f847b-147b-4f8b-a31d-a926b46f5f98-2026-10-31 2026-09-03T15:18:22 104 20,375 0 0
renewal-lease_3505f7d6-a674-4ab5-90c3-b403a2062e51-2026-10-31 2026-09-03T15:18:20 104 20,405 0 0
renewal-lease_400b86d9-3bf0-4818-b8a5-3126c0ed9b55-2026-11-30 2026-09-18T14:30:06 109 22,124 0 0

Assessment: none of the 16 is stuck. Zero pending activities, zero pending children, no retry backoff anywhere, history ~104–122 events / ~20–25 KB — two orders of magnitude below any Temporal warning threshold.

What they are holding on. Tail of renewal-lease_fa611644-…-2026-12-31:

 94 2026-09-12T15:18:10 ACTIVITY_TASK_SCHEDULED act=sendReminderSms
 96 2026-09-12T15:18:12 ACTIVITY_TASK_COMPLETED
100 2026-09-12T15:18:12 MARKER_RECORDED
101 2026-09-12T15:18:12 UPSERT_WORKFLOW_SEARCH_ATTRIBUTES
104 2026-09-12T15:18:12 TIMER_STARTED  timer=5  dur=6856907.765s

Last real work was a successful sendReminderSms on 2026-09-12, then it parked on one timer of 6,856,907 s ≈ 79.4 days — a single sleep to the next renewal milestone. Correct behaviour, but see §7: one 79-day timer means 79 days of silence with nothing to alert on if it is wrong.

Flagged: executedLeaseFetchWorkflow — 19 of 22 Running, unbounded poll loop

Oldest: exec-lease-fetch-1773625953462-transfer-1031, started 2026-09-03T15:45:09Z, still running after 20 days, history length 693, 0 pending activities.

Event census for that run:

WORKFLOW_EXECUTION_STARTED 1 | WORKFLOW_TASK_* 126 each
ACTIVITY_TASK_SCHEDULED 63 | STARTED 63 | COMPLETED 63
TIMER_STARTED 63 | TIMER_FIRED 62
...
687 2026-09-23T16:20:14 ACTIVITY_TASK_SCHEDULED act=fetchAndCacheExecutedLeaseActivity
689 2026-09-23T16:20:33 ACTIVITY_TASK_COMPLETED
693 2026-09-23T16:20:33 TIMER_STARTED dur=86400s

So: 63 successful poll iterations, each one fetchAndCacheExecutedLeaseActivity → success → sleep 86,400 s, loop, forever. The activity never fails (so no retry alarm ever fires) and the workflow never gives up. executed-lease-fetch-workflow.ts does contain a continueAsNew path, but it has never fired in production — the observed ContinuedAsNew count for this type is Total: 0, so in practice history grows ~11 events/iteration with no reset. One id is literally exec-lease-fetch-appfolio-403-transfer-890 (started 2026-09-13T16:30:08) — named after an AppFolio 403, still polling 10 days later. This is the one genuinely unhealthy pattern in the renewal family: 19 workflows that will poll AppFolio daily forever for an executed lease that is apparently never going to appear.


4. Failed / Terminated / TimedOut

Failed — 25 namespace-wide, only 2 are renewalWorkflow

Both are harness probes, both on 2026-09-20, both historyLength: 29:

  • worker-version-probe-1789895665729 — started 2026-09-20T09:14:26Z, closed 09:14:42Z
  • smith-reprobe-1789896489692 — started 2026-09-20T09:28:09Z, closed 09:28:25Z

Root cause (identical for both), from the ACTIVITY_TASK_FAILED event:

Activity function pinRenewalScopeActivity is not registered on this Worker,
available activities: ["classifyPrepareFailure","decideOfferPreparedTransition",
"dispatchAppFolioWrite","dispatchPmsPrepare","dispatchVoiceCall",
"escalateHeldRenewalActivity","escalateToPM","extractPrepFailureDetail","loadSa…
type: NotFoundError

This is a worker/workflow-code skew: workflow code calling pinRenewalScopeActivity was live while the deployed worker did not register it. It is exactly the failure class Worker Versioning / Build IDs exists to prevent, and the queue is UNVERSIONED (§6). This also explains the single red nightly harness run, GitHub Actions run 35501662838, 2026-09-20T09:12:25Z, failure in 2m26s — its log shows Posted the harness failure alert to #alerts. So the alerting path worked.

The other 23 Failed are not renewal: 22 maintenanceCommsWorkflow (maintenance-comms-wo-WILL-0029xx…003381, 2026-09-04 → 2026-09-22, ~1/day) and 1 benchRobotCallWorkflow (bench-robocall, 2026-09-04T14:39:51). Sample cause on maintenance-comms-wo-WILL-003381: Maestro comms: WO WILL-003381 not found / type InvalidPayload. Steady daily rate, different worker, worth its own ticket but outside renewals.

Terminated — 319 namespace-wide, 132 are renewalWorkflow — none are customer renewals

Type Terminated
renewalWorkflow 132
prospectOutreachWorkflow 105
pmActionReminderWorkflow 73
prospectReengagementWorkflow 6
tourWorkflow 2
turnoverWorkflow 1

Every terminated renewalWorkflow has a saga_<uuid> workflow id (e.g. saga_0ee5311e-3e39-4d6d-a1a2-eb97fbbff86f, saga_bfcd0c7d-8726-40fc-8670-bdd7de5c4105), not the customer renewal-lease_…-<leaseEnd> shape. Termination reasons sampled: matrix cell cleanup (×4) and matrix cleanup (×2), all at 2026-09-23T09:18–09:20Z, history 52–124 events.

Daily distribution is a flat 6/day from 2026-09-05 to 2026-09-23 (21 on 09-04, 3 on 09-03, and 09-20 missing — the day the harness went red). This is the nightly eval matrix tearing down its own cells, i.e. expected harness hygiene, not production breakage. It does mean the raw "132 terminated renewals" number is badly misleading on any dashboard that does not filter saga_*.

TimedOut — 50, zero renewal

Type TimedOut
maintenanceCommsWorkflow 26
walkTurnoverCohortWorkflow 18
walkCollectionsCohortWorkflow 6

Examples: turnover-cohort-walker-wf-2026-09-23T11:03:00Z (start 11:03:00, close 11:13:00, history length 6) — a 10-minute run timeout hit with only 6 events, i.e. the run never got past its first workflow task; collections-cohort-walker-daily-wf-2026-09-23T09:00:00Z; maintenance-comms-wo-WILL-003082 (2026-09-09T07:31:48). 18 walker timeouts against 9,692 fires is ~0.19%, but the 6-event shape says "no worker progress", not "slow work".

Non-determinism

No non-determinism errors surfaced in the renewal family in the window: 0 renewalWorkflow TimedOut, and the only 2 Failed are the registration skew above. A renewalWorkflow non-determinism error would normally appear as a wedged run with a stuck workflow task — none of the 16 Running has one.


5. Schedules — 12 registered on propflow-prod.hmqma

Schedule id Workflow type Task queue Spec TZ Paused Actions taken Last runs
renewal-cohort-walker-daily walkRenewalCohortWorkflow propflow-renewal daily 08:00 America/Denver no 20 09-21/22/23 T14:00:00Z all fired
renewal-auto-start-daily renewalAutoStartScanWorkflow propflow-renewal daily 08:30 America/Denver no 20 09-21/22/23 T14:30:00Z all fired
pm-action-reminder-backstop walkPmActionReminderBackstopWorkflow propflow-leasing hourly UTC no 488 09-23 T21:00, T22:00, T23:00
turnover-cohort-walker walkTurnoverCohortWorkflow propflow-turnover every 3 min UTC no 9,692 09-23 T22:57, T23:00, T23:03
collections-cohort-walker-daily walkCollectionsCohortWorkflow propflow-collections-chase daily 09:00 UTC no 20 09-21/22/23 T09:00:00Z
tour-cohort-walker — — — — no — next 2026-09-23T23:15Z
topic-label-sweep-nightly — — — — no — next 2026-09-24T09:00Z
bench-borrow-expiry-daily — — — — no — next 2026-09-24T09:30Z
onsite-pricing-daily — — — — no — next 2026-09-24T12:00Z
voice-injection-cache-refresh-daily — — — — no — next 2026-09-24T12:30Z
conversation-review-daily conversationReviewScanWorkflow propflow-renewal daily 22:00 UTC PAUSED 0 next-action frozen at 2026-09-03T22:00Z
availability-warm-cache-refresh availabilityWarmCacheRefreshWorkflow propflow-onsite-pricing every 2 h UTC PAUSED 0 next-action frozen at 2026-09-03T04:00Z
  • missedCatchupWindow is null (zero) on every schedule checked — no catch-up misses.
  • The 10 unpaused schedules carry the note ADR-0045 cut — activating target schedules.
  • Both paused schedules are paused intentionally, per their own notes: conversation-review-daily — "Daily deterministic conversation pre-filter. The LLM reviewer pass is retired (2026-08-28, audit gap…)"; availability-warm-cache-refresh — "Background refresher for the warm tour-availability cache (Willows-first rollout gate — only touches…)". Neither is a renewal schedule silently dead; both frozen at 2026-09-03 because that is the cut date.
  • The two renewal schedules and the pm-action-reminder backstop are all healthy and current (last fire within the hour of this check).

Repo-side schedule facts worth carrying

  • conversation-review-daily is paused by a standing human directive recorded in its live Temporal note: "Killed by Fede 2026-08-15 — the 22:00Z 20-parallel-call volley; do not unpause without his say-so." Do not unpause.
  • availability-warm-cache-refresh never existed on the source namespace; it is a Willows-first rollout gate, paused by design.
  • Cron drift is real and documented. renewal-auto-start-daily runs live at 30 8 America/Denver while the code default in client.ts is 0 9 UTC — anyone re-upserting with defaults moves it ~6 hours. adr0045-namespace-contract.ts exists specifically to pin the live values.
  • EXCLUDED_SCHEDULES — grader-coverage-daily, maintenance-eval-daily, pms-sync-eval-daily — are declared in code but registered on no namespace, which is why only 12 of the 15 defined schedules appear above.
  • scheduling-engaged-walker was orphaned (its workflow was deleted) and dropped at the cut; TOMBSTONE_SCHEDULES is intentionally empty.
  • pm-action-reminder-backstop ships PAUSED from scripts/temporal/upsert-pm-action-reminder-backstop-schedule.ts unless --armed is passed; it is correctly armed in prod (488 actions).

6. Worker health

Live pollers on propflow-renewal (temporal task-queue describe)

BuildID       Type      ApproximateBacklogCount  BacklogAge  TasksAddRate  TasksDispatchRate
UNVERSIONED   activity                        0          0s             0                  0
UNVERSIONED   workflow                        0          0s             0                  0

UNVERSIONED  activity  propflow-renewal-45d628b9cf0f-ip-172-31-6-144.ec2.internal    21s ago
UNVERSIONED  activity  propflow-renewal-37d1bc5f5ba7-ip-172-31-77-6.ec2.internal     22s ago
UNVERSIONED  activity  propflow-renewal-37d1bc5f5ba7-ip-172-31-6-5.ec2.internal      24s ago
UNVERSIONED  activity  propflow-renewal-45d628b9cf0f-ip-172-31-65-244.ec2.internal   29s ago
UNVERSIONED  workflow  propflow-renewal-45d628b9cf0f-ip-172-31-6-144.ec2.internal    24s ago
UNVERSIONED  workflow  propflow-renewal-37d1bc5f5ba7-ip-172-31-6-5.ec2.internal      25s ago
UNVERSIONED  workflow  propflow-renewal-45d628b9cf0f-ip-172-31-65-244.ec2.internal   41s ago
UNVERSIONED  workflow  propflow-renewal-37d1bc5f5ba7-ip-172-31-77-6.ec2.internal     53s ago
  • 4 worker processes polling, last access 21–53 s ago, zero backlog, zero backlog age. The queue is not starved.
  • Build ID is UNVERSIONED for every poller — Worker Versioning is not in use (see §7).
  • Two distinct task/image hashes are polling at once (45d628b9cf0f, 37d1bc5f5ba7), consistent with the in-flight deploy below. Running executions carry 4–6 entries in their BuildIds search attribute (["unversioned","unversioned:3af2605880a7","unversioned:ec661ba713dd","unversioned:f6869e8c3ce9"] on one sample) — mixed-code-version execution is normal here.

Last deploys — gh run list --workflow deploy-renewal-worker.yml --limit 5 (no --watch)

Run id Status Trigger Started Duration
35932127952 pending workflow_run 2026-09-23T23:09:13Z 3m17s
35931136168 in_progress workflow_run 2026-09-23T22:57:45Z 14m45s
35930422076 success workflow_run 2026-09-23T22:49:25Z 15m12s
35930004506 success workflow_run 2026-09-23T22:44:40Z 10m11s
35929936967 skipped workflow_run 2026-09-23T22:43:54Z 2s

Deploys fire off every successful CI run on main (workflow_run chained, no cron), ~10–15 min each. Three deploys were in flight inside 26 minutes — 35930422076 (15m12s, from 22:49) was still running when 35931136168 started at 22:57, and 35932127952 started at 23:09.

Note on the 4 pollers: the prod service runs desiredCount: 2 with maximumPercent: 200, so 2 old + 2 new tasks during a rolling deploy is the expected steady state mid-deploy, not a leak. What is not ideal is the rate — chained workflow_run deploys with no concurrency group mean a new rollout can begin before aws ecs wait services-stable has settled the previous one, which widens the window in which two different code versions serve the same queue.

Nightly renewal harness — gh run list --workflow nightly-renewal-harness.yml --limit 5

Run id Result Date
35841749119 success 2026-09-23T09:14:21Z
35709170749 success 2026-09-22T09:13:35Z
35582503675 success 2026-09-21T09:18:15Z
35501662838 failure 2026-09-20T09:12:25Z
35434053367 success 2026-09-19T09:12:19Z

4/5 green; the one red is the activity-registration skew in §4, and it did post to #alerts.

Renewal replay gate — gh run list --workflow renewal-replay.yml --limit 5

5/5 success (2026-09-22T23:08Z → 2026-09-23T20:57Z), all pull_request-triggered. History replay is running per-PR and passing — this is the main defence against non-determinism, and it is working.

Sentry

Not queried (optional, and querying it was outside what this read-only pass ran). The helper does exist: .claude/skills/sentry/SKILL.md (/sentry issues|issue <id>|investigate <id>|stats), org propflow-ai, project javascript-nextjs, token from Keychain propflow-sentry-token:

SENTRY_TOKEN=$(security find-generic-password -a sentry -s propflow-sentry-token -w)
curl -s -H "Authorization: Bearer $SENTRY_TOKEN" \
  "https://sentry.io/api/0/projects/propflow-ai/javascript-nextjs/issues/?query=is:unresolved&statsPeriod=24h&limit=25"

Temporal-relevant runbooks to pair with it: docs/runbooks/sentry-temporal-unreachable.md, docs/runbooks/sentry-temporal-api-key-expiring.md, docs/runbooks/sentry-turnover-stall.md. No Sentry numbers are stated here rather than guessed.

Nondeterminism alerting — a stub, and it is off

  • src/lib/temporal/nondeterminism-alert.ts (ADR-0052) exports captureNondeterminismAlert(), isNondeterminismFailureReason() (matches nondetermin* or code TMPRL1100) and TEMPORAL_WORKFLOW_TASK_FAILED_METRIC = 'temporal_workflow_task_execution_failed'. It was written after the letter_mms_in_workflow_v1 patch wedged migrated Camellia renewals for a week with zero alerting.
  • src/lib/temporal/nondeterminism-metrics-drain.ts — startNondeterminismMetricsAlert() is called from services/renewal-worker/index.ts, but it is feature-flagged off by default (TEMPORAL_NONDETERMINISM_METRICS_ALERT=true) and the MetricsBuffer.retrieveUpdates() wiring was never implemented — drainFn defaults to returning nothing. A known bug-in-waiting is documented inline: the Sentry task_queue tag is hardcoded from opts.taskQueue, so once the drain does land, tour/turnover/maintenance non-determinism will be mis-tagged propflow-renewal.
  • So the live defence against non-determinism is entirely pre-merge: src/__tests__/renewal-workflow-replay-corpus.test.ts + .github/workflows/renewal-replay.yml (5/5 green above), the patch ledger guard src/__tests__/temporal-patched-ledger.drift.test.ts, and the prod-history canary scripts/temporal/export-recent-histories.ts. There is no runtime alarm.

6b. Renewal workflow shape (src/lib/temporal/workflows/renewal-workflow.ts, 3,743 lines)

  • continueAsNew: NO. renewalWorkflow never calls it. Only maintenanceCommsWorkflow, prospectReengagementWorkflow, executedLeaseFetchWorkflow and conversationGraderWorkflow do — and of those, only conversationGraderWorkflow actually fires it in prod. client.ts:239 states outright that "continueAsNew before the ceiling is the real fix and remains out of scope".
  • Signals (6): pmsChanged, pmsFailed, deliveryStatus, tenantIntent, pmAction, pmStatusOverride. Updates (3): retryLetter, externalResolution, pmDecision. Query (1): getSnapshot.
  • Long waits: condition(() => !isPaused() || isSettledOrTerminal()) with no timeout (unbounded pause hold, line 1356); holdover park condition(isSettledOrTerminal) with no timeout (2471) plus a wake-at timer (2499); awaiting response 24 h; reminder step 1 24 h, step 2 7 days; awaiting countersign 14 days; expiry nudges per expiry-nudge-schedule.ts. All overridable via input.timeouts.
  • Versioning: patched() / deprecatePatch() — no getVersion, no version constant. 8 live patch gates (renewal_scope_pin_v1…_v4, renewal_commitment_stated_phase_v1, countersign_reminder_requires_signature_v1, renewal_infra_classifier_budget_v1, renewal_negotiating_intent_v1) and ~16 retired deprecatePatch markers kept for replayability. Worker Versioning is stamped but disabled: buildId comes from PROPFLOW_GIT_SHA/GIT_COMMIT, useVersioning is gated on TEMPORAL_USE_VERSIONING === 'true' which is unset in the task def — matching the UNVERSIONED build id on every live poller.
  • Retry policies are inline at the proxyActivities call sites, not in retry-policies.ts: shortActivities 60 s startToClose, 1 s/×2/60 s/5 attempts; projectionActivities 5 s, 500 ms/×2/2 s/2; longActivities (dispatchVoiceCall, dispatchAppFolioWrite, sendRenewalLetterMmsActivity) 10 min startToClose + 60 s heartbeat, 2 s/×2/2 m/5; pmReminderActivities 30 s, 1 s/×2/30 s/3. All carry nonRetryableErrorTypes: ['InvalidPayload','InvalidCredentials','PermissionDenied'].
  • Idempotency: yes, and it is the good kind. activityIdempotencyKey(name) = ` ${workflowId}-${runId}-${activityName}-${activitySeq++} — a monotone counter advanced only when an activity is scheduled, with runId in the key so a reopened workflow cannot collide. It replaced a historyLength-based key that drifted on deduped signals and caused duplicate Twilio/SES sends. Applied to sendOutreachSms, sendOutreachEmail, dispatchVoiceCall, sendReminderSms-step1/2, sendRenewalLetterMmsActivity, escalateToPM, dispatchPmsPrepare:${attempt} and others; retryLetter` uses an event-derived key instead.

7. Assessment

Overall: Temporal is solid for renewals. The evidence: 16 running customer renewals with 0 pending activities, 0 retry backoff, and 104–122-event histories; 0 renewal timeouts; 0 renewal non-determinism observed; both renewal schedules and the hourly PM-reminder backstop fired on time with 0 catch-up misses; 4 healthy pollers with 0 backlog and 0 backlog age on propflow-renewal; the per-PR replay gate green 5/5; a real patched() discipline with a ledger drift test; and a well-designed activity idempotency key that already survived one duplicate-send incident. The only production-facing renewal failures in the window were 2 synthetic harness probes, and their root cause was fixed the same day. Nothing is wedged and nothing is silently dead.

Two numbers distort the picture and must not be read as breakage: the 132 "terminated renewals" are all saga_* nightly-eval matrix cells, and the 2026-09-03 start times are a migration artifact.

The honest weak spot is not the workflows — it is observability and history growth. Runtime non-determinism alerting is a stub that is off, and nothing in the renewal family ever calls continueAsNew.

Top concrete improvements

  1. Bound executedLeaseFetchWorkflow — CERTAIN. 19 of 22 are Running; exec-lease-fetch-1773625953462-transfer-1031 (started 2026-09-03T15:45:09Z) is at history 693 after 63 successful daily polls. exec-lease-fetch-appfolio-403-transfer-890 has polled for 10 days since an AppFolio 403. The continueAsNew path in the file has never fired (ContinuedAsNew Total: 0). Give the loop a deadline (lease end + N days), lower the continueAsNew threshold so it actually triggers, and escalate on give-up. Today this fails silently forever because the activity always succeeds — no retry alarm can ever catch it.
  1. Wire the non-determinism metrics drain and turn it on — CERTAIN. nondeterminism-metrics-drain.ts is called from the prod entrypoint but drainFn returns nothing (MetricsBuffer.retrieveUpdates() was never implemented) and it is gated off by TEMPORAL_NONDETERMINISM_METRICS_ALERT. This is the exact gap that let letter_mms_in_workflow_v1 wedge migrated Camellia renewals for a week unnoticed. Fix the hardcoded task_queue tag at the same time, or tour/turnover/maintenance events will all report as propflow-renewal.
  1. Add continueAsNew to renewalWorkflow — LIKELY. Zero ContinuedAsNew across the whole renewal family. Current histories (~104 events, ~20 KB) are far from any limit, so this is not urgent — but the workflow has a 14-day countersign wait, a 7-day reminder wait, and two condition() waits with no timeout at all (the pause hold at line 1356 and the holdover park at 2471). A renewal that pauses, holds over and re-offers has no reset point, and 15 patch markers already ride on a single run.
  1. Enable Worker Versioning — LIKELY. Every poller is UNVERSIONED; buildId is already stamped from the git SHA and useVersioning is one env var away. Running executions carry 4–6 mixed unversioned:<hash> build ids, and three deploys overlapped inside 26 minutes on the day of this check. Determinism safety currently rests entirely on patched() discipline plus replay corpora — good discipline, but with no runtime net (see #2). Add a concurrency group to deploy-renewal-worker.yml so a rollout cannot start before aws ecs wait services-stable settles the previous one.
  1. The 2026-09-20 skew is already fixed — verify, do not re-fix — CERTAIN. Both failed renewalWorkflow probes died on Activity function pinRenewalScopeActivity is not registered on this Worker / NotFoundError. src/lib/temporal/workers/renewal-worker-activities.ts was extracted that same day to make the dev runner and the Fargate entrypoint share one activities map, pinned by src/__tests__/renewal-worker-bridge-registration.test.ts. The harness has been green 09-21/22/23 since. Remaining residue: the two files still fork on concurrency (10/10 dev vs 5/5 prod).
  1. **Filter saga_* out of renewal dashboards — CERTAIN.** 132 of 132 terminated renewals are eval matrix cells at a flat 6/day (matrix cell cleanup / matrix cleanup). Any alert or metric on "terminated renewals" is currently 100% noise, which is precisely how a real termination gets missed.
  1. Shorten the 79-day timer, or add a self-check — LIKELY. One run is parked on a single 6,856,907 s timer with its last activity on 2026-09-12. That is legitimate Temporal usage, but it means ~79 days with no observable progress and nothing that would notice a mis-computed milestone. Stepped timers turn a wrong sleep into a same-week detection instead of a Q1 surprise.
  1. Prune the patch stack — LIKELY. Running executions carry 14–18 TemporalChangeVersion entries (defer_outreach_to_send_window_v1, letter_mms_in_workflow_v1, outreach_parallel_v2, phase0_load_saga_state_v1, renewal_honor_pause_v1, renewal_workflow_owned_dispatch_v1, …). With only 16 live runs, all recreated 2026-09-03, most can be retired via deprecatePatch before the branches become permanent.
  1. Put Temporal Schedules under the drift check — LIKELY. renewal-auto-start-daily runs live at 30 8 America/Denver while the code default is 0 9 UTC; re-upserting with defaults moves it ~6 hours. .github/workflows/schedule-drift.yml reconciles EventBridge and vercel.json crons but explicitly does not cover Temporal Schedules — the only thing pinning the live values is adr0045-namespace-contract.ts, a hand-maintained file.
  1. Fix the cohort-walker run timeouts — LIKELY (outside renewals). 18 walkTurnoverCohortWorkflow and 6 walkCollectionsCohortWorkflow timed out; turnover-cohort-walker-wf-2026-09-23T11:03:00Z closed at exactly +10m00s with history length 6 — no worker progress, not slow work. 0.19% of 9,692 fires, but that shape points at worker capacity or a poison first task on propflow-turnover. Separately, 22 maintenanceCommsWorkflow failures at ~1/day, all Maestro comms: WO … not found / InvalidPayload.
  1. Recover or accept the loss of pre-cut history — CERTAIN. propflow-renewal-prod.hmqma is NXDOMAIN, so no renewal execution history exists before 2026-09-03 and there is no 90-day baseline. If quarter-scale renewal trends matter, export executions somewhere durable (scripts/temporal/export-recent-histories.ts exists); otherwise every future audit restarts at the last namespace cut.

Queries that could not be run

  • GROUP BY WorkflowType — rejected by Temporal Cloud: "operation is not supported: 'GROUP BY' clause is not supported for search attribute WorkflowType". Worked around with one workflow count per type.
  • Old namespace propflow-renewal-prod.hmqma — failed reaching server: no children to pick from; nslookup returns NXDOMAIN. The namespace no longer exists, so the pre-2026-09-03 portion of the 90-day window is unavailable. Not a credentials problem.
  • Sentry — not queried (optional; no repo helper confirmed read-only).
Camellia real renewal history (June 25 – Dec 31)

Source: scratchpad/report-camellia-history.md

Camellia Apartments — real renewal history, 2026-06-25 → 2026-12-31

Read-only analysis of production data. Property: Camellia Apartments, PropFlow property 1773625953462, AppFolio jpco.appfolio.com property 7, org org_jpco. Renewals live at Camellia since 2026-07-23 (ADR-0108). Generated 2026-09-23.

No number below is estimated unless labelled ESTIMATE. Every figure is reproducible from the scripts named beside it.


0. Method, and a correction to the existing tooling

Scripts used (all read-only; all in the session scratchpad, not the repo)

Script What it read
scratchpad/stage1.ts DynamoLeaseRepository.getLeases('1773625953462',{includeArchived:true}) → 157 leases; DynamoPropertyRepository.getTenants(...) → 149 occupancies; getDynamoSagaStorage().getAllSagas() → 165 sagas (120 Camellia); dynamoPersonRepository.getPersonsByIds(ids,'org_jpco'); AppfolioClient.listAll('renewal_summary',{'properties[]':7}) → 115 rows; getActivityEvents('renewal', sagaId) per saga
scratchpad/stage2.ts DynamoConversationRepository.getConversationsByPersonId(personId) for every case person → 94 conversation heads
scratchpad/stage3.ts getConversation(id) to hydrate messages → 594 messages (the GSI path returns meta only)
scratchpad/stage4.ts Same, for the 9 already-renewed cases missed by the first window filter → 111 conversations total
scratchpad/analyze.py Joins and all aggregates below

Environment: DYNAMODB_TABLE_NAME=propflow-prod, DATA_BACKEND=dynamodb, AWS_REGION=us-east-1, AWS identity arn:aws:iam::194925971150:user/fede-admin. Nothing was written to DynamoDB, AppFolio, Temporal or Slack. No tenant was contacted.

Two tooling defects found on the way in — both matter beyond this report

(a) scripts/_audit-camellia-renewals.ts is stale and reports 23 false CRITICAL findings. It joins sagas to tenants on saga.tenantId. That field no longer exists: production RenewalSaga records key on personId and leaseId (verified — the field set on 120 live Camellia sagas is GSI4PK, GSI4SK, closedAt, closedReason, holdoverOfficeNotifiedAt, id, lastError, lastRunStartedAt, leaseId, migratedAt, migratedFromState, negotiatingCondition, negotiatingSince, openedAt, openedBy, personId, pmsRef, policySnapshot, precededBy, propertyId, state, successorClaim, tenantSignedNotifiedPmsRef, transferredToUnitNumber, updatedAt, version, workflowId). The join therefore resolves nothing, every tenant prints as ?, and every AppFolio cross-check fails open, producing 23 spurious saga.state=EXECUTED but NO AppFolio renewal_summary row exists CRITICALs. Anyone who has run this script since the ADR-0035 D2 person-spine migration has been reading noise. Its comment also asserts that "PropFlow saga.pmsRef does NOT correspond to AppFolio OccupancyId … the only reliable cross-reference is by UnitName + tenant last name." That is now wrong too: TenantOccupancy.pmsOccupancyRef joins cleanly to renewal_summary.OccupancyId (24/43 in-window occupancies matched directly; the rest have no AppFolio renewal row at all, which is the real finding).

(b) A window filter on PropFlow Lease.endDate silently hides every successful renewal. When a renewal completes, the lease's endDate is advanced to the new end date, so the case drops out of a "lease ends in the window" query. My first pass found 43 cases and zero renewals. Re-deriving the window from AppFolio renewal_summary.PreviousLeaseEnd recovered 9 more units — 203, 210, 215, 401, 411, 417, 503, 511, 607 — all nine of them the renewals. Any dashboard or report that windows on current endDate under-reports the renewal rate to zero. Final case set: 52.


1. The renewal cases in the window

52 renewal cycles whose original lease end falls 2026-06-25 → 2026-12-31. Names are first-name + unit.

Rent columns: rent = rent in force; offer = the 12-month renewal rent Clara prepared; mkt = Lease.marketRent as synced from AppFolio.

# Tenant Unit Orig. lease end Latest saga state Offer prepared Terms offered (6mo / 12mo / MTM) Outcome Decision date First touch → decision Human involved
1 Gabriela 324 2026-06-30 none — — vacate (NTV) — — occupancy stamped NTV; Clara never engaged
2 Ana 403 2026-06-30 MIGRATED_TO_WORKFLOW 2026-05-07 1150/1150/1250 vacate (NTV 2026-06-01) 2026-06-09 n/a PM opened the saga
3 Katlyn 414 2026-06-30 MIGRATED_TO_WORKFLOW 2026-05-07 1350/1350/1450 vacate (NTV 2026-06-09) 2026-06-09 n/a PM opened the saga
4 Sarah 201 2026-07-31 TRANSFERRED — — transferred 2026-07-23 n/a —
5 Audra 212 2026-07-31 NTV_RECEIVED 2026-07-17 1350/1350/1450 vacate (NTV 2026-07-31) 2026-08-06 14 d —
6 Ricardo 224 2026-07-31 NTV_RECEIVED — — vacate (NTV 2026-07-01) — — —
7 Emilio 518 2026-07-31 NTV_RECEIVED 2026-06-03 1750/1750/1850 vacate (NTV 2026-07-01) — — flagged to PM 2026-06-12
8 Jason 306 2026-08-31 PREPARE_FAILED (open) — — stuck — AppFolio has a live Pending offer sent 08/10 — — 5 sagas; see §4
9 Ryan 308 2026-08-31 TRANSFERRED 2026-06-08 1650/1650/1750 transferred 2026-07-27 — —
10 Keinibel 422 2026-08-31 NTV_RECEIVED — — vacate (NTV 2026-07-01) — — —
11 Simone 603 2026-08-31 NTV_RECEIVED 2026-06-23 1150/1150/1250 declined → NTV, same day 2026-06-24 5 h 36 m none — clean autonomous path
12 Maxwell 606 2026-08-31 NTV_RECEIVED — — vacate (NTV 2026-08-01) — — human sent a manual make-good text 08-30
13 Gabriel 204 2026-09-30 PREPARE_FAILED (open since 2026-07-13) — — stuck — but texted a renewal rate anyway on 09-15 — — PM notified 09-16, one day after the tenant
14 Luis 416 2026-09-30 NTV_RECEIVED 2026-07-08 1150/1150/1250 vacate 2026-09-08 — 10 sagas, 9 escalation events; see §4
15 Ilasmin 515 2026-09-30 NTV_RECEIVED 2026-07-04 1150/1150/1250 vacate 2026-09-09 — 3 sagas; AppFolio 422
16 Jessenia 524 2026-09-30 CANCELLED 2026-07-04 1350/1350/1450 vacate — — 3 sagas; MMS undeliverable
17 Darius 620 2026-09-30 MIGRATED_TO_WORKFLOW 2026-07-13 1350/1350/1450 did not renew — — —
18 David 105 2026-10-31 MIGRATED_TO_WORKFLOW 2026-08-02 1197/1197/1297 undecided (AppFolio Pending since 08/02) — — —
19 Gabriela 115 2026-10-31 NTV_RECEIVED — — vacate (NTV 2026-06-01) — — —
20 David 311 2026-10-31 TRANSFERRED — — transferred — — —
21 Brooklyn 402 2026-10-31 NTV_RECEIVED 2026-08-02 1350/1350/1450 vacate (NTV 2026-08-23) 2026-08-23 21 d —
22 Corinne 512 2026-10-31 MIGRATED_TO_WORKFLOW 2026-09-03 1350/1350/1450 undecided (Pending) — — stale EXECUTED saga from 04-30 still attached
23 Yudi 610 2026-10-31 NTV_RECEIVED 2026-08-02 1150/1150/1250 vacate — — —
24 Brenden 622 2026-10-31 MIGRATED_TO_WORKFLOW 2026-08-02 1350/1350/1450 undecided (Pending) — — —
25 Eloisa 114 2026-11-30 OFFER_PREPARED 2026-09-18 1350/1350/1450 undecided (Pending) — — escalation tool failed mid-call; see §3
26 Mario 220 2026-11-30 PREPARE_FAILED (open) 2026-09-03 1350/1350/1450 stuck — — flagged to PM 09-03
27 Andrew 222 2026-11-30 MIGRATED_TO_WORKFLOW 2026-09-01 1350/1350/1450 undecided (Pending) — — —
28 Kevin 301 2026-11-30 none — — never started — — —
29 Chris 307 2026-11-30 MIGRATED_TO_WORKFLOW 2026-09-01 1240/1240/1340 undecided (Pending) — — —
30 Cesar 317 2026-11-30 NTV_RECEIVED 2026-09-03 1150/1150/1250 vacate 2026-09-08 — prepare failed first, flagged to PM
31 Brian 409 2026-11-30 PREPARE_FAILED (open) 2026-09-03 1240/1240/1340 stuck — — flagged to PM 09-03
32 Lorraine 410 2026-11-30 MIGRATED_TO_WORKFLOW 2026-09-01 1150/1150/1250 undecided (Pending) — — —
33 Deb 505 2026-11-30 MIGRATED_TO_WORKFLOW 2026-09-02 1150/1150/1250 undecided (Pending) — — —
34 Carl 507 2026-11-30 MIGRATED_TO_WORKFLOW 2026-09-01 1150/1150/1250 undecided (Pending) — — —
35 Ron 510 2026-11-30 MIGRATED_TO_WORKFLOW 2026-09-01 1160/1160/1260 undecided (Pending) — — —
36 Caleb 120 2026-12-31 TRANSFERRED 2026-05-07 1350/1350/1450 transferred 2026-07-27 — stale EXECUTED saga from 05-29
37 Gregory 122 2026-12-31 none — — never started — — —
38 Rodulf 201 2026-12-31 MIGRATED_TO_WORKFLOW 2026-09-03 1000/1000/1100 undecided (Pending) — — call crossed with a prospect call; see §3
39 Emma 302 2026-12-31 MIGRATED_TO_WORKFLOW 2026-09-03 1350/1350/1450 undecided (Pending) — — stale EXECUTED saga from 05-05
40 Milo 303 2026-12-31 MIGRATED_TO_WORKFLOW 2026-09-03 1150/1150/1250 undecided (Pending) — — —
41 Desiree 609 2026-12-31 MIGRATED_TO_WORKFLOW 2026-09-03 1150/1150/1250 undecided (Pending) — — —
42 Sherayna 616 2026-12-31 NTV_RECEIVED 2026-05-07 1150/1150/1250 vacate (NTV 2026-08-01) 2026-08-01 — —
43 Carolina 618 2026-12-31 MIGRATED_TO_WORKFLOW 2026-09-03 1750/1750/1850 undecided (Pending) — — —
44 Aria 503 2026-09-30 EXECUTED 2026-07-02 1150/1150/1250 renewed 12-mo @ $1,150 2026-07-07 4 d none
45 David 203 2026-09-30 EXECUTED 2026-07-02 1150/1150/1250 renewed 6-mo @ $1,150 2026-07-27 24 d none
46 Sunny 411 2026-09-30 EXECUTED 2026-07-02 1150/1150/1250 renewed 6-mo @ $1,150 2026-07-27 24 d none
47 Megan 511 2026-09-30 CANCELLED (see §6) 2026-07-05 1150/1150/1250 renewed 12-mo @ $1,150 2026-07-16 10 d none
48 Ekene 210 2026-08-31 EXECUTED 2026-07-10 1150/1150/1250 renewed 12-mo @ $1,150 2026-07-27 16 d 2 human-authored texts through Clara (concession question)
49 Juan 401 2026-08-31 EXECUTED 2026-06-21 1150/1150/1250 renewed 12-mo @ $1,150 2026-08-20 59 d none
50 Paula 607 2026-08-31 HUMAN_REVIEW_PENDING (see §6) 2026-07-10 1150/1150/1250 renewed 12-mo @ $1,150 2026-09-08 59 d counter-offer by email → PM handled
51 Sonya 215 2026-10-31 MIGRATED_TO_WORKFLOW (see §6) 2026-09-03 1150/1150/1250 renewed 12-mo @ $1,150 2026-09-16 12 d none
52 Susan 417 2026-12-31 OFFER_PREPARED (see §6) 2026-09-03 — (never sent) renewed 12-mo @ $1,191 2026-09-16 n/a — Clara never contacted her PM did the whole thing manually

Outcome totals

Outcome Cases Share
Vacate / notice to vacate 19 37%
Undecided / in flight 15 29%
Renewed (countersigned in AppFolio) 9 17%
Transferred 4 8%
Stuck — PREPARE_FAILED, still open 3 6%
Never started (no saga, no outreach) 2 4%

Resolved cases (renewed + declined/vacate + transferred) = 32. Renewal rate among resolved cases: 9/32 = 28%.

Median time from first outreach to countersignature, over the 9 renewals: 16 days (range 4–59).


2. The channel funnel

What actually got sent (Lease.channelOutcomes, Lease.renewal*SentAt, Lease.preparedOffer)

Step Cases
Offer prepared in AppFolio 41 of 52
Renewal letter (MMS + SMS) actually sent 31
MMS delivered 29
MMS undelivered (Twilio 30005, unreachable handset — units 505, 524) 3
SMS delivered 29
SMS undelivered 1
Voice attempt recorded 33 (16 connected, 17 attempted_no_answer)
Reminder #1 sent (~2 days after the letter) 32
Reminder #2 sent (~9 days after the letter) 26
Expiry nudge (30/15-day band) 3
Escalated to a PM (renewalFlaggedForPmAt) 10
Email used as a renewal outreach channel 0

Reply rate by channel

Counting inbound tenant messages on the case threads (Message.role === 'tenant', direction derived per message-provenance.ts):

Channel Inbound messages Cases with ≥1 reply Cases reached on that channel Reply rate
SMS 26 6 30 delivered 20%
Voice 173 32 33 attempts see below — almost all of it is machines
Email 3 1 (Paula, 607) 0 sent inbound only

The voice numbers are misleading on their face: 173 "tenant" turns on voice threads are overwhelmingly transcribed voicemail greetings and call-screening robots, which the ASR records as tenant speech. See §3.

Which channel produced the decision

Of the 9 renewals:

Decision came from Cases Detail
Tenant signed silently in the AppFolio portal after SMS touches 5 203, 411, 401, 511, 503 (503 replied only to say she had already signed)
Tenant stated the decision in the SMS thread with Clara 2 210 (Ekene — negotiated a concession over SMS and signed), 215 (Sonya — "Yes sorry I'm resigning" 12 min after reminder #2)
Email, escalated to a human 1 607 (Paula — counter-offered $1,000 by email; PM settled it)
PM did it entirely by hand 1 417 (Susan — Clara sent nothing)
A phone call 0 —

The only decline that arrived through Clara also came by SMS: Simone (603) replied 39 minutes after the letter text, and had her notice to vacate filed in the portal 5 h 36 m after first contact.

Text is the channel that works. The call has produced zero decisions in this window.

Fede's hypothesis, quantified

"people don't answer the call for the most part"

Confirmed, and it is worse than the stored outcome flag says. 26 renewal calls were placed in the window. Classifying each from its stored transcript (Conversation.messages, kind='renewal_call'):

Classification Calls Share
Reached the actual tenant, live 2 7.7%
Reached a different human (household member / crossed line) 2 7.7%
Voicemail greeting recorded 16 61.5%
Call-screening robot that never handed over 5 19.2%
— of which the mailbox was unusable (full, or never set up) 2 —

2 of 26 calls (7.7%) reached the person they were for. Zero produced a renewal decision.

Lease.channelOutcomes.voice says connected for 12 of these 26 calls. Only 2 of those 12 actually reached the tenant — a 83% false-positive rate on the "connected" label. The flag is set whenever the far side produces speech, so voicemail greetings ("This is Deborah, leave me a message") and screening bots ("I'll see if this person is available") are all recorded as connected. Any dashboard, eval or policy that keys off channelOutcomes.voice === 'connected' is reading a number that is wrong five times out of six.

What happened after an unanswered call

Clara leaves a voicemail — "Sorry I missed you — I will text you the details so you can look them over whenever you have a minute" — and the cadence continues on SMS. The promise is kept: every case with a recorded call also has the letter SMS.

But the sequencing is the finding. The text does not follow the call; it precedes it by about a minute. Median gap from letter SMS to the renewal call: 1.3 minutes; 26 of 27 same-cycle pairs had the call inside 5 minutes of the text. So the voicemail's "I will text you the details" describes a text the tenant already received before the phone rang. The call adds a ring, a voicemail and an AI-disclosure risk, and adds no information the SMS did not already carry 60 seconds earlier.

After the voicemail the ladder is: reminder #1 at ~2 days, reminder #2 at ~9 days, then nothing until an expiry nudge inside the last 30 days (only 3 cases reached that band). There is no second call attempt in any case in the window — voiceDispatch.attempts is 0 on all 11 leases that carry the field.

Outreach timing

Median first outreach is 89 days before the original lease end (n=31; range 14–119). Distribution: 90+ days 15 cases, 60–89 days 7, 30–59 days 6, under 30 days 3. Three cases were first contacted inside 30 days of lease end (unit 212 at 14 days, unit 414 at 21, unit 620 at 27). The 119-day cases are the 2026-12-31 cohort, contacted 2026-09-03 — a month before the 90-day window is supposed to open.


3. The calls, one by one

All 26 in-window renewal calls, from the stored transcripts. ElevenLabs conversation ids are given for audio pull-through.

Date Tenant / unit ElevenLabs id Two-line summary Class
06-25 Megan 511 conv_... (voice thread, renewal_call) Voicemail greeting; Clara left the renewal message. Collided with a live inbound maintenance call from the same tenant in the same minute — two agents talking in one thread. Voicemail
06-26 Rodulf 201 conv_1301m1ksy448ffc86sarv9fb8x72 Renewal call hit voicemail, and the thread simultaneously carried an inbound Spanish-speaking prospect (Rodolfo (unit 201)). Clara pivoted to leasing, booked a 4:15 PM tour, sent a tour confirmation. Wrong person / crossed thread
07-02 David 203 — "Message for [resident phone number removed]", then silence. Voicemail. Renewed 25 days later via the portal. Voicemail (flagged connected)
07-02 Aria 503 — Standard voicemail greeting; Clara left the renewal message. She had already signed. Voicemail
07-02 Sunny 411 — Voicemail. Renewed 25 days later via the portal. Voicemail
07-04 Jessenia 524 conv_1501kwptgbe0e7m9s2y13q6d2hp6 "…has a voicemail box that hasn't been set up yet. Goodbye." No message could be left. Voicemail — unusable
07-10 Ekene 210 — Voicemail. She replied to the SMS 10 days later and renewed. Voicemail
07-10 Paula 607 — Voicemail. She replied by email three weeks later with a counter-offer. Voicemail
07-13 Darius 620 conv_6801kxdybyyjf4w9fpktqjqrs06m "Darius (unit 620) is not available. The mailbox is full and cannot accept any messages at this time." Nothing delivered. Voicemail — unusable
08-02 David 105 conv_9301kz1e77f5ftk8h5advxm8g0dc Live human. Answered "What?", then "Shut up, scammer." Clara de-escalated calmly, re-identified herself, and read the three options. No decision; still Pending 52 days later. Answered — hostile, no decision
08-02 Brooklyn 402 conv_9901kz1e9wd3f3sbvn7xg2cekwx2 "Please leave a message after the tone." Voicemail left. She filed notice to vacate 21 days later. Voicemail
08-02 Yudi 610 conv_0601kz1ecwzxevhb5ep49vhj5d0x Spanish-language screening robot: "revisaré si esta persona está disponible… permanece en la línea." Never handed over. Screening robot (flagged connected)
08-02 Brenden 622 conv_6701kz1efbarfzrb6wwhax9ktke8 English screening robot: "I'll see if this person is available… please stay on the line." Never handed over. Screening robot (flagged connected)
09-01 Andrew 222 conv_3801m1ep2n8mehm8f687pgdsrrgf Voicemail. Message left. Voicemail
09-01 Chris 307 conv_8801m1ep390cen8a1gbe6dvampzn Carrier voicemail greeting, transcribed as tenant speech. Message left. Voicemail (flagged connected)
09-01 Lorraine 410 conv_8701m1ep4aabf8jtxkxa19658x3h Voicemail. Message left. Voicemail
09-01 Carl 507 conv_1501m1ep584tfz7913v72yb63am4 Voicemail loop — "I couldn't hear you, please try again" three times. Message left on the third pass. Voicemail (flagged connected)
09-01 Ron 510 conv_4301m1ep72h6f9hsccjee36wx9c4 "…0477 is not available." Clara said out loud "Looks like I may have reached a voicemail or the line isn't connecting" and then read the script anyway. Voicemail
09-02 Deb 505 conv_4101m1hcfm91fnjv9xga05d7an78 "This is Deborah, leave me a message." Message left. Voicemail
09-03 Sonya 215 — Screening robot; never handed over. She renewed 13 days later via SMS. Screening robot (flagged connected)
09-03 Emma 302 conv_1201m1ks6ne3ehzbw8a560sc57x7 Voicemail. Message left. Voicemail
09-03 Milo 303 conv_5701m1ks7eshe13ta34h9e0nf2jp Screening robot handed to a person who was not Milo ("Is this Milo?" — "No."). Clara correctly refused to disclose renewal terms to a third party and asked them to have Milo call back. Wrong person — handled correctly
09-03 Corinne 512 conv_4001m1ksvxsrexctt1j32p6fr70n Voicemail. Message left. Voicemail (flagged connected)
09-03 Carolina 618 conv_4101m1ksxbeaedk8scdyx3v73f1h Call-recording/screening service asked who was calling; Clara identified herself and named the tenant and lease end to the robot, then held indefinitely. Never connected. Screening robot (flagged connected)
09-03 Desiree 609 conv_3701m1kswmn3fey949jt5mxfykp4 "Cloaked AI" screening service → hold → voicemail. Clara left the message. Screening robot (flagged connected)
09-18 Eloisa 114 conv_5801m2tetyh2frf95a9pm3f2nggj Live human. Spanish-dominant; visibly confused throughout ("You've been banned from the hotel"). Clara read all three options in English, got no decision, tried to escalate — and the renewal_escalate tool failed, so Clara told her to call the office herself. Answered — confused, escalation failed

Hallucination / wrong-fact review

I found no fabricated rents, terms or dates in any of the 26 transcripts. Every rate Clara spoke matches the preparedOffer on the lease. Specific checks:

  • David 105: spoke "$1,197, 6 and 12 month" — matches preparedOffer.terms exactly.
  • Eloisa 114: spoke "$1,350 / $1,350 / month-to-month $1,450" — matches exactly.
  • Carolina 618: told a recording service "her lease ends December 31st" — correct.

Two non-hallucination wrong-fact moments worth naming:

  1. Ron 510 — Clara narrated her own uncertainty into the recording: "Looks like I may have reached a voicemail or the line isn't connecting." That sentence was left on the tenant's voicemail.
  2. Milo 303 — Clara asked a stranger "Is this Milo?" after having already opened with "this is Clara from Camellia Apartments" and the reason for the call. The unit and the fact of a lease renewal were disclosed before identity was established.

The transfer path has a separate, previously-known defect visible here: Maxwell 606 (08-30) asked for management, Clara transferred, the far side picked up and nobody spoke (transferOutcome: answered_no_voice), and the caller was dropped. A human sent a make-good text 65 minutes later.


4. Where it broke

Every case where the system did the wrong thing, with ids and dates.

D1 — Retry storm: 10 sagas for one lease (Luis, unit 416). lease_... unit 416, 2026-07-02 → 2026-09-08. Eight CANCELLED sagas opened in six days — 07-02 14:30, 07-07 02:54, 07-07 03:28, 07-07 04:23, 07-07 05:21, 07-08 02:30, 07-08 03:01, 07-08 03:30 — then PREPARE_FAILED on 07-13 with lastError: "PMS prepare timeout (10 minutes)", then NTV_RECEIVED on 09-08. Nine escalation events fired. The tenant received zero messages across the whole storm (channelOutcomes is null, no renewal messages on the thread). Root cause looks like the classic AbortSignal.timeout < runner maxDuration retry loop (runbook §5, PR #607 class), re-minting a saga per retry instead of resuming the claim. Unit 515 (3 sagas) and unit 524 (3 sagas) show the same shape on the same dates.

D2 — Four PREPARE_FAILED sagas still open, one for 72 days. Units 204 (open since 2026-07-13), 306 (since 2026-08-10), 220, 317, 409 (all since 2026-09-03). Errors: "PMS prepare timeout (10 minutes)" ×3, "PMS prepare failed: Activity task failed" ×3, and one AppFolio 422 on unit 515: PMS_REJECTED httpStatus=422 … HTTP 422 on /renewal_offers/317/send_all_to_tportal: {"title":"Lease could not be sent"…}. None of these self-healed and none were retried to success.

D3 — A tenant was texted a renewal rate for an offer that does not exist (Gabriel, unit 204). Most serious defect in the window. The saga has been PREPARE_FAILED since 2026-07-13. No offer was ever prepared (preparedOffer is null; there is no AppFolio row at all for the unit). On 2026-09-15 14:30 — 64 days later, 15 days before lease end — Clara sent: "Lock in your renewal rate of $1,350/mo for 12 months by signing before then — otherwise your lease rolls to month-to-month at $1,450/mo. Reply here and we'll send you the signing link." There is no signing link to send. The next day, 2026-09-16 17:00, the PM was emailed "Renewal blocked — a signer is missing an email … the PMS won't accept the offer." The tenant was quoted a price and a deadline a full day before anyone told the office the renewal was impossible. This is the holdover/expiry-nudge path firing without checking that an offer exists — the same structural class as the 2026-07-31 incident (an un-gated send path above the decision spine), recurring on a different predicate.

D4 — channelOutcomes.voice = 'connected' is wrong in 10 of 12 cases. See §2. Units 203, 610, 622, 307, 507, 215, 303, 512, 618, 609 are all recorded as connected when no human tenant was ever on the line.

D5 — Escalation tool failure during a live call (Eloisa, unit 114, 2026-09-18 14:36). The renewal_escalate tool call returned an error mid-conversation. Clara's recovery was honest — "I wasn't able to flag that just now, so I'd suggest reaching out to the leasing office directly" — but a Spanish-speaking resident who could not follow the English offer was handed the work of calling the office herself, and nothing was recorded for the team. The identical failure appears on Ekene 210 (2026-07-20 14:50): "I wasn't able to send that through just now — I'll make sure the property team gets your question." Two escalation-tool failures in 52 cases.

D6 — Stale terminal sagas still attached to live cycles. Units 512, 302, 215 each carry an EXECUTED saga from 2026-04-30 / 2026-05-05 alongside the current cycle's saga; unit 120 carries one from 2026-05-29. This is the known "page projects Renewed forever" class from the runbook. Unit 215 is the live example: it is genuinely renewed now, but it was showing an EXECUTED saga from April, five months before the renewal that actually happened.

D7 — Offers land above the property's own advertised rent in 90% of cases. 37 of the 41 prepared 12-month offers are priced above that unit's synced marketRent. Median gap +$150/mo, max +$240/mo (units 307 and 409: offered $1,240, market $1,000 — 24% above). The offer equals the tenant's current rent in every case, so this is a flat renewal, not an increase — but AppFolio's market rent for the same units dropped roughly $150 across the board, and the offer was never re-evaluated. This is exactly Paula's (607) documented complaint generalised: she counter-offered $1,000 citing the property's own website, and she was right. Nothing in the system invalidates a live OFFER_PREPARED saga when a price drop lands.

D8 — Two leases never entered the funnel at all. Units 301 and 122 (both ending 2026-11-30 / 2026-12-31) have no saga, no offer and no outreach of any kind.

D9 — Undeliverable MMS was not retried or escalated. Units 505 and 524: channelOutcomeDetails.letterMms = "Invalid or unreachable number — verify the tenant's phone in the PMS (Twilio 30005: unreachable handset)". The message names the fix, and nobody was asked to do it. Unit 524's voicemail box was also not set up (07-04 call), so that tenant was unreachable on every channel and no one was told.

D10 — No second call attempt, ever. voiceDispatch.attempts === 0 on all 11 leases carrying the field. A single dial at a single time of day is the entire voice strategy. All September calls went out in a 14:30–15:45 UTC block — 08:30–09:45 Mountain, a weekday working morning.


5. Where it worked well

Simone, unit 603 — the cleanest case in the dataset. Letter MMS 06-24 17:55, call to voicemail 17:56, tenant replied by SMS at 18:35 asking about her lease end date. Clara corrected her, she said she was moving back to Baltimore, Clara walked her to the portal, and she confirmed "I just submitted my notice to vacate in the portal" at 23:31 — 5 h 36 m from first contact to a filed, portal-recorded decision, entirely autonomous, no human touched it. A decline, but a perfect one: the unit was released for turnover two months early.

Aria, unit 503 — 4 days, no friction. Letter 07-02, reminder 07-04, tenant replied 48 minutes after the reminder ("I signed my renewal yesterday! Did I miss something?"), Clara confirmed her renewal state and closed an open work order in the same thread. Countersigned 07-07.

Sonya, unit 215 — the reminder did the work. Letter 09-03, call screened out, reminder #1 09-05, reminder #2 09-12 15:18 — and 12 minutes later she texted "Yes sorry I'm resigning". Clara confirmed the term and rate correctly and pointed her to the portal. Countersigned 09-16, 13 days end to end.

Ekene, unit 210 — the hardest win, and Clara's handling was right. Voicemail on 07-10, two reminders, then a long SMS on 07-20 asking about the "1 month free" promotion. Clara did not invent a concession; she flagged it and a human answered accurately that the promo was for new leases. Ekene came back on 07-23 asking why a 2-week concession wasn't in the document; Clara flagged again; the human resolved it; "Great thanks just signed!!" on 07-24. Countersigned 07-27. Sixteen days, one genuine negotiation, zero fabricated terms.

Milo, unit 303 — a refusal worth keeping. A stranger answered and said "just send me a piece of mail". Clara declined: "I'm not able to share the account details with anyone other than Milo directly." Correct privacy behaviour under pressure, unprompted.

David, unit 105 — de-escalation. Called a scammer by a resident and stayed calm, re-identified, and delivered the offer accurately. No decision, but no damage.

Autonomous renewals with no human in the loop: 6 of 9 (503, 203, 411, 511, 401, 215). Two needed a human for a concession or price question (210, 607); one bypassed Clara entirely (417).


6. The human side, and where PropFlow and AppFolio disagree today

What the Camellia team did by hand

  • Unit 417 (Susan) — the PM ran the entire renewal. Clara prepared an offer on 09-03 but letterSentAt is empty, channelOutcomes is null, and the tenant has zero messages. The offer was flagged to the PM at 09-03 14:07. AppFolio shows it sent 09/03 and countersigned 09/16 at $1,191. The renewal happened; PropFlow contributed nothing to it after the prepare.
  • Unit 210 (Ekene) — two manual_outbound messages, human-authored, sent through Clara's thread on 07-21 and 07-23 to answer the concession question. This is the takeover path working as designed.
  • Unit 606 (Maxwell) — a human sent a make-good text on 08-30 after the transfer dropped him.
  • Units 403 and 414 — both sagas were opened by a PM (RenewalOpened … Saga opened by pm), not the scanner.
  • 10 cases were flagged to a PM (renewalFlaggedForPmAt): 518, 603, 416, 515, 220, 317, 409, 616, 607, 417. Seven of those ten were escalations about a system failure (prepare failed, missing signer email), not about a tenant.
  • pm_renewal_notify emails fired correctly on completion for 203, 411, 210, 401, 511 and on blockage for 204 and 607.

Disagreements between PropFlow and AppFolio, as of 2026-09-23

Five cases. In every one, AppFolio is right and PropFlow is stale.

Unit AppFolio says PropFlow saga says Impact
607 (Paula) Renewed, countersigned 09/08/2026, 12-mo @ $1,150 HUMAN_REVIEW_PENDING, still open, negotiatingSince set The renewals page shows a live negotiation on a lease signed 15 days ago
511 (Megan) Renewed, countersigned 07/16/2026 CANCELLED (last of 3 sagas) Renewal invisible to PropFlow; the EXECUTED saga in the middle was superseded by a CANCELLED one
215 (Sonya) Renewed, countersigned 09/16/2026 MIGRATED_TO_WORKFLOW Not projected as renewed
417 (Susan) Renewed, countersigned 09/16/2026 OFFER_PREPARED, still open Shows as awaiting response on a signed lease
306 (Jason) Pending offer, sent 08/10/2026 PREPARE_FAILED, still open PropFlow believes the offer never went out; the tenant has it

4 of the 9 renewals (44%) are not reflected as renewed in PropFlow's saga state. Combined with the windowing defect in §0(b), PropFlow's own view of Camellia renewals is materially wrong in both directions: it hides the successes from a date-windowed query, and for nearly half of them the saga never reached a terminal renewed state.


7. Opportunities, ranked

1. Stop calling first. Make the call a later, opt-in step — or drop it. 2 of 26 calls (7.7%) reached the intended tenant; 0 of 26 produced a decision; 0 of the 9 renewals came from a call. Meanwhile the letter SMS precedes the call by a median of 1.3 minutes, so the call carries no information the tenant does not already have. 21 of 26 calls hit a voicemail or a screening robot, and 5 of those screening robots were told the tenant's name, unit and lease-renewal business before hanging up. Removing the first-touch call costs nothing measurable in this dataset and removes the "shut up, scammer" failure mode (unit 105) and the third-party-disclosure exposure (units 303, 618). Numbers: §2, §3.

2. Fix channelOutcomes.voice. Right now it is wrong 83% of the time. 10 of 12 connected labels were voicemail or a screening robot. Until this is corrected, every renewal metric, eval and policy decision that reads it is built on a false signal — including any future evaluation of opportunity #1. The transcripts already contain the evidence (a tool_result carrying "At the tone, please record your message", or the classic screening-robot phrases); classify from the transcript, not from the presence of far-side audio. Numbers: §2, §3.

3. Re-price the offer when the unit's market rent drops. 37 of 41 offers (90%) sit above the property's own market rent, by a median of $150/mo. Units 307 and 409 are being asked to renew at $1,240 against a $1,000 market rent — 24% above. One resident (607) already caught this and counter-offered, and she was right; she cost the team a manual negotiation and 59 days. A live OFFER_PREPARED saga should be flagged (not auto-repriced) when a synced PRICE_CHANGE drop lands on its unit, and the PM should see it before the tenant does. Numbers: §4 D7.

4. Never send a price to a tenant when no offer exists. Gate every renewal outbound on a live prepared offer. Unit 204 was texted a $1,350 rate and a signing deadline on 09-15 for an offer that had been PREPARE_FAILED since 07-13, and the PM learned about the blockage the next day. One occurrence in 52 cases, but it is the highest-severity class here: a quoted price with no signable document, and the tenant told before the office. Numbers: §4 D3.

5. Make PREPARE_FAILED self-healing, and never leave it open for 72 days. 4 sagas are open in PREPARE_FAILED right now (units 204, 306, 220, 317, 409); the oldest has been failed for 72 days. Two distinct causes — PMS prepare timeout (10 minutes) and Activity task failed — plus one AppFolio 422. Add a bounded retry with backoff and a hard SLA that converts an unrecovered prepare into a PM task within 24 hours, not 64 days. Numbers: §4 D2.

6. Key the saga claim on the lease cycle, not the attempt. Unit 416 minted 10 sagas and contacted the tenant zero times. Eight cancelled sagas in six days on one lease, nine escalation events, no message ever sent. Units 515 and 524 show the same pattern. The claim should be (leaseId, leaseEndDate) so a retry resumes rather than re-mints. Numbers: §4 D1.

7. Close the loop from AppFolio back to the saga. 4 of 9 renewals (44%) are not marked renewed in PropFlow. Units 607, 511, 215, 417 are all countersigned in AppFolio and not EXECUTED in PropFlow; unit 306 has a live Pending offer PropFlow thinks failed. A reconciler that reads renewal_summary.CountersignedDate and drives the saga to terminal would fix all five, and would also fix the windowing problem in §0(b) by recording the original cycle end on the saga. Numbers: §6.

8. Lead with SMS and lean on reminder #2 — it is the highest-yield touch in the funnel. SMS reply rate is 20% (6 of 30 delivered), and two of the nine renewals plus the only clean decline were decided in the SMS thread. Sonya (215) answered 12 minutes after reminder #2; Ekene (210) answered the day after reminder #2; Aria (503) answered 48 minutes after reminder #1. Only 26 of 32 cases got a second reminder and only 3 ever reached the expiry-nudge band — there is room for a third touch before the last 30 days. Numbers: §2.

9. Handle Spanish. Two of the four calls that reached a human failed on language. Eloisa (114) is a Spanish-dominant resident who answered, could not follow the English offer, and got no escalation because the tool failed. Yudi (610) was screened by a Spanish-language robot. Camellia's own inbound leasing traffic is served in Spanish (unit 201's prospect thread, 06-26), so the capability exists on the leasing side and is absent on the renewal side. Numbers: §3.

10. Fix the escalation tool, and never make the resident do the escalating. renewal_escalate failed twice in 52 cases (units 114, 210). On unit 114 the recovery text told the resident to phone the office herself. A failed escalation should retry and, failing that, write a PM task — never hand the work to the tenant. Numbers: §4 D5.

11. Act on undeliverable contact details instead of recording them. Units 505 and 524 have Twilio 30005: unreachable handset on the MMS, and 524's voicemail box was also unconfigured. The stored detail string literally says "verify the tenant's phone in the PMS" and no one was asked to. These tenants are unreachable on every channel and the team does not know. Numbers: §4 D9.

12. Two leases never entered the funnel. Units 301 and 122 have no saga, no offer, no outreach. Worth a scanner-coverage check before the next cohort. Numbers: §4 D8.


Appendix — raw artefacts in the scratchpad

File Contents
stage1.json 157 leases, 149 occupancies, 120 sagas, 90 persons, 115 AppFolio rows, activity events per saga
stage2.json 111 conversation heads by personId
stage3.json Hydrated conversations, 594+ messages
dossier.txt Per-case dump: sagas, events, offers, AppFolio rows, full message timeline (1,852 lines)
calls.txt Full transcripts of every in-window renewal call
outcomes.txt The outcome table
analyze.py The join and every aggregate above
Prior art: local renewal replay lab

Source: scratchpad/report-harness.md

Prior art: a local renewal replay lab (Camellia, 12 months, AppFolio mocked)

Read-only research, 2026-09-23. Repo main checkout /Users/federicochapa/.claude/propflowai at e05e8826ad. Facts carry paths. Lines marked [inference] are mine, not the repo's.


0. Headline

The lab Fede is describing mostly already exists, in three pieces that have never been joined:

  1. scripts/renewal-harness/replay/ — a hermetic, in-process 30-simulated-day walk of the real daily

renewal scan over a seeded book. Real code, fake DynamoDB, fake Temporal, fake Twilio. Runs on every renewal PR. Synthetic data only.

  1. testing-harness/ (top level) — a local Temporal dev server rig with a

scenarios/production-replay.ts that reads real Camellia sagas + their event logs out of prod DDB and drives them through the real renewalWorkflow, comparing the workflow's outcome to the saga's actual final state. Real data, real Temporal, but a PoC frozen at 14 sagas and stub activities.

  1. src/__tests__/renewal-workflow-replay-corpus.test.ts + scripts/canary/ — real exported prod

Temporal histories replayed against current workflow code for determinism.

The work is joining #1's hermetic fakes to #2's real-data reader and widening the window from 14 sagas to 12 months, not building a rig. Per the harness skill §0/§5, this extends scripts/renewal-harness/ and testing-harness/; it does not justify a new *-harness directory.


1. Inventory of existing renewal harnesses / replays / evals

# Path What it replays Asserts Mutation control CI Last green
1 scripts/renewal-harness/replay/ (__tests__/renewal-replay.test.ts 457 ln, portfolio.ts 600 ln, in-memory-dynamo.ts 463 ln, doubles.ts, recorders.ts, setup-env.ts, ci-path-filter.ts 164 ln, own vitest.config.ts) Synthetic 3-property book, 30 simulated days of the real daily scan Sept-3 incident invariants: every start inside the property's own window (not the 120-day default); ≤1 PM page per (lease, reason); exactly one saga per tenant per cycle. Plus -lapse-digest, -lapsed-selfblocked-offer, -infra-failure, ntv-email-written-date Partial — scripts/mutation-controls/renewal-replay-path-filter.control.ts guards only the path filter, not the invariants [gap] .github/workflows/renewal-replay.yml, PR path-filtered, not yet a required check ("a separate, deliberate step once it has a few weeks of clean history") 2026-09-23 20:57 UTC, run 35919413106, 2m09s. Last 8 PR runs all green
2 testing-harness/ (scenarios/production-replay.ts 421 ln, run-all.ts 7 synthetic scenarios, mock-pms-e2e.ts, canary-fede-chapa.ts, pm-notify-matrix.ts, pms-conformance.ts, inspect-saga.ts) REAL prod Camellia data — scans RSAGA#* META + LOG#renewal#{id} events at property 1773625953462, translates each event to a Temporal signal, drives a real workflow on a local temporal server start-dev Workflow final outcome vs saga's real final state (PASS / MISMATCH table). Its README already documents one prod finding: a saga whose state disagreed with its own event log None None. README: ".github/workflows/temporal-scenarios.yml (TODO — lands with Phase 3)"; the file does not exist Unknown — on-demand tool, no run history
3 src/__tests__/renewal-workflow-replay-corpus.test.ts + src/__tests__/fixtures/temporal-histories/ Real exported prod histories (temporal workflow show -o json, scrubbed by scripts/temporal/scrub-history-fixture.ts), replayed with the real SDK replayer Determinism: no new activity/patch/timer/reordering an in-flight history can't accommodate. Origin: 2026-05-27, PR #1214 froze 3 migrated Camellia renewals for 13 days None registered Pinned in ci.yml Green (blocking gate)
4 .github/workflows/nightly-renewal-harness.yml job harness → scripts/canary/e2e-permutation-matrix.ts Live permutation gauntlet at AppFolio test property 45 against tenant tenant-gauntlet-appfolio-45, prod DDB + prod Temporal Cloud namespace propflow-prod.hmqma. Tiers 0/1/4, --no-real-comms, terms 6/12/mtm Workflow state machine + signal dedup; readiness.md artifact of failing cells n/a (live gauntlet) Nightly 09:00 UTC 2026-09-23 success (35841749119, 6m13s). 9/22, 9/21 green; 9/20 failed (35501662838, 2m26s); 9/14–9/19 green
5 Same file, job prod-history-replay-canary — this is the "prod-history replay canary" Downloads the ≤10 most-recently-closed renewalWorkflow histories closed within 30 days from Temporal Cloud (scripts/temporal/export-recent-histories.ts), scrubs, replays against current worker code. Does not commit fixtures Live-cohort replayability. Zero histories exported = red, no page None Nightly, same workflow Reports separately from harness; run-level failures 9/20
6 Same file, job willows-live-run → scripts/renewal-harness/willows-live-run.ts (+ willows-seed/reset/verify/dryrun/policy.ts, renewal-gate-preview.ts, renewal-start-locks.ts) Full production renewal path for real at Willows only: real scan, real Temporal start on an isolated queue, real AppFolio sandbox prepare, real SMS/email to Willows fixtures End-to-end live path n/a workflow_dispatch only, default OFF opt-in
7 scripts/canary/replay-verifier.ts Lists every open prod Temporal workflow, describes + queries each snapshot Signs of non-determinism / state inconsistency. "Run before flipping Camellia on" None Nightly, continue-on-error: true — known chronically red on 3 open Camellia *-migrated workflows carrying letter_mms_in_workflow_v1 non-determinism Non-blocking; known red
8 scripts/renewal-eligibility-replay.ts (335 ln) Real computeRenewalFeatures + evaluateRenewalPolicy, zero mocks, over a real AppFolio charge_detail JSON dump, at two threshold configs Decision-diff table. This is how Camellia's lateGraceDays 10→25 was validated None None (on-demand) n/a
9 scripts/renewal-shadow-diff.ts (569 ln) Every renewal READ surface evaluated twice against live prod records, legacy blob vs loadRenewalPolicyView Byte-level parity with absent-vs-undefined distinction None None n/a
10 scripts/renewal-external-reconcile-harness.ts (59 ln) ADR-0091 external-actor reconciler against live prod, OBSERVE by default Classification report per property None None n/a
11 scripts/_audit-camellia-renewals.ts (429 ln) Every saga at Camellia + AppFolio renewal_summary rows for the same occupancy + activity event counts Saga↔AppFolio consistency, monotonic timestamps, conversation presence for sent sagas. Writes /tmp/camellia-renewals-audit.json None None n/a
12 .github/workflows/renewal-evals{,-sms,-scoping}.yml, scripts/eval-renewal-scoping-subscription.ts, evals/datasets/renewals-sms.yaml, npm run test:renewals-regression (src/__tests__/regression/renewals) Prompt/message-layer renewal wording Judge scores n/a CI n/a
13 scripts/option-d-proof/ (34 files) + src/__tests__/household-harness/corpus/{jpco,situs,western-slope}/ Three committed, already-scrubbed customer corpora seeded into a tmpdir JSON store; graded on invariants / equivalence / status-table / stability. Guards: ddb-sandbox-guard.ts, option-d-env.ts, drift test option-d-proof-seed-no-write-imports.drift.test.ts Not renewals — identity/household/deal partitioning Yes — 5 controls incl. option-d-ddb-sandbox-guard.control.ts, option-d-proof-write-fence.control.ts npm run proof:option-d:run, test:option-d-seed n/a

Related but not renewal: evals/replay-harness/ (the atlas's RECOMMENDED conversation engine, enters at the real production entrypoint, isolation verified live against AWS STS), scripts/replay-harness/, quality-replay/.


2. What a "replay 12 months of real Camellia renewals in a local lab" needs

Fede's revised target: local lab, no CI, no PR review, leases ending 2025-09 → 2026-12, AppFolio mocked. I keep the 90-day framing where it changes the answer.

(a) Data capture

Needed Reuse / missing
Renewal sagas + their event logs REUSE. testing-harness/scenarios/production-replay.ts already queries RSAGA#{id} SK=META and LOG#renewal#{id} at Camellia 1773625953462 and has a translateEvent event→signal mapping table. Read-only, never writes. Widening it to a 12-month window is a date-filter change, not new code
Per-saga cross-check against AppFolio REUSE. scripts/_audit-camellia-renewals.ts already joins each Camellia saga to its AppFolio renewal_summary rows (AppFolio property id 7) and emits consistency findings
Real prod Temporal histories (the ground truth for what the workflow actually did) REUSE. scripts/temporal/export-recent-histories.ts — but it is bounded at --limit=10 and --closed-within-days=30. A 12-month pull needs the bounds widened [small change]
Charge/balance features for the eligibility engine REUSE. scripts/renewal-eligibility-replay.ts consumes a real AppFolio charge_detail dump. That dump is uncommitted by rule (PII); it must be pulled to a scratch dir outside the repo
Conversation / message / call logs for the resident side MISSING for renewals. Conversation replay exists (evals/replay-harness/, 9 competing implementations per the atlas) but nothing joins a renewal saga to its conversation thread as replayable input. This is the largest genuinely-new piece
Scrubbing REUSE, pick one: scripts/promise-harness/scrub.ts (the skill's mandated full-replace scrubber), scripts/temporal/scrub-history-fixture.ts (money keys + worker identity, for histories), scripts/household-harness/scrub-corpus.ts, src/lib/platform/security/scrub-pii-log-string.ts (production log masker). A shared scripts/harness/scrub.ts is still proposed, not built

[inference] 12 months of Camellia is roughly 120 units (e2e/fixtures/appfolio-camellia-scrubbed.csv has 121 rows) → order-of-magnitude 100–150 renewal cycles. The testing-harness PoC covered 14. Volume is not the problem; the 2025-09→2026-05 slice predates several of today's saga fields and the 2026-05-20 cutover, so early histories will not replay at all (see §4).

(b) Sandbox

Needed Reuse / missing
Store the replay writes into REUSE, two options, both already proven. (i) scripts/renewal-harness/replay/setup-env.ts — JSON store bound to a fresh mkdtemp dir with an empty seed layer, DYNAMODB_TABLE_NAME=propflow-replay-harness-does-not-exist, AWS/Temporal/Twilio creds deleted from env. (ii) scripts/option-d-proof/option-d-env.ts + ddb-sandbox-guard.ts — same tmpdir posture with a hard preflight refusing any table name containing prod
DynamoDB REUSE — and better than dynamodb-local. scripts/renewal-harness/replay/in-memory-dynamo.ts (463 ln) is an in-process document client that really evaluates condition expressions, TTLs and key composition, and throws on any command outside the renewal slice so an unproven path can't read as an empty result. It exists precisely because the mint claim, dedupe ledger, sandbox PMS ledger and touch budget all go straight to @/lib/data/dynamo/helpers. There is no dynamodb-local or LocalStack anywhere in the repo (one aspirational mention in docs/planning/portfolio-architecture/how/attack-study-hardened-mutation.md:71) and no msw / nock / aws-sdk-client-mock / dynalite / testcontainers dependency. [inference] Don't introduce dynamodb-local — the in-memory table is the repo's answer and is already trusted by a CI job.
AppFolio PARTIAL. Writes: src/lib/integrations/appfolio/pms-writer-sandbox.ts — SandboxAppfolioPMSWriter, dispatched by pmsSource: 'appfolio-sandbox' through src/lib/domain/pms/writer-registry.ts. Structurally cannot reach AppFolio (imports neither l4-core nor pms-adapter-factory, fenced by src/__tests__/pms-writer-sandbox-no-appfolio-imports.drift.test.ts), records every would-be write to src/lib/data/dynamo/sandbox-pms-ledger.ts, returns realistic deterministic fake ids. Flip a property onto it with scripts/set-property-pms-sandbox-mode.ts (dry-run default, refuses non-isTest). Reads: only the thin makePmsReadClient() in scripts/renewal-harness/replay/doubles.ts — returns clean features, no recorded fixtures. A recorded-fixture AppFolio read layer is MISSING. mock-pms-service/ (a whole deployed Next app) mocks Yardi SIPP and RealPage Knock only — no AppFolio. src/lib/integrations/appfolio/demo-database.ts / demo-fixture.ts exist for demos [unverified as a replay source]
Temporal REUSE both. See (c)
The "haven" / sandbox org Fede's word doesn't appear in the repo. Every haven hit is haven't, a fake resident name, or the competitor Haven.ai. [inference] he means org_sandbox — the real sandbox organization: Yale 25 Station (Sandbox) (yale-sandbox, line +17196011047), AppFolio test property 45 / Willows (appfolio-45, line +18442853526), fairhaven-homes (portfolio bench, +17208071724). It is the single entry in config/flipped-dispatch-orgs.json, is treated as non-customer by evals/replay-harness/goldmine/lib.ts:124 (NON_CUSTOMER_ORGS), and has its own Slack feed (config/channels.toml:105). Worth one confirming line from Fede before building on the assumption

(c) Time control

Option State
Local temporal server start-dev Fully supported and routinely used. testing-harness/README.md documents it as step 2 of quick start; ~15 planning/eval docs depend on it. The trap, repeated in 8 handoff docs: after set -a && source .env.local && set +a you must unset TEMPORAL_API_KEY TEMPORAL_ADDRESS TEMPORAL_NAMESPACE, because .env.local pins Temporal Cloud and src/lib/temporal/connection.ts routes to Cloud the instant TEMPORAL_API_KEY is set (docs/planning/armed-lifecycle-gates-handoff.md:125). Namespace on a dev server is default (docs/planning/temporal-namespace-migration.md:46). No temporal CLI is vendored — brew install temporal
@temporalio/testing TestWorkflowEnvironment time-skipping Supported and already used in ~12 renewal/turnover/tour/collections workflow tests under src/__tests__/ (e.g. renewal-workflow-expiry-nudge-lazy-leaseend.test.ts, renewal-workflow-countersign-reminder-resilience.test.ts, turnover-saga-chaos.test.ts). testing-harness/README.md's coverage table lists "Long-running timer expiration (14d countersign timeout) → TODO — needs TestWorkflowEnvironment time-skipping" — i.e. the PoC replayer drives real timers shortened to 500ms, which is why it can't prove the 14-day paths
Simulated-day loop, no Temporal at all scripts/renewal-harness/replay/ advances its own clock over 30 days (REPLAY_EPOCH, REPLAY_DAYS) and stands Temporal in for with makeWorkflowStarter() — which reproduces exactly the property that matters, workflow-id idempotency (USE_EXISTING)

[inference] Recommendation for a 12-month lab: use all three in layers. The scan-level walk (what fires, when, to whom, how often) on the simulated-day loop with no Temporal, scaled from 30 days to ~480; the per-renewal workflow behaviour (14-day countersign, reminder cadence, quiet hours) on TestWorkflowEnvironment time-skipping, because a 12-month wall-clock run is impossible otherwise; and start-dev only for the handful of cases you want to watch in the Temporal Web UI at :8233. Time-skipping is the only one of the three that makes a year of timers cheap.

(d) Tenant simulator

MISSING as a renewal-specific piece. The nearest pieces: scripts/camellia-replay-scenarios.json (verbatim PII-stripped real Camellia resident texts, scenario-shaped, leasing not renewals), scripts/coworker-harness/, scripts/journey-harness/, evals/replay-harness/. The testing-harness replayer's translateEvent() is the closest thing to a renewal tenant simulator — it already turns a real event log into ordered signals — but it replays what the saga recorded, not the resident's raw words, so it cannot answer "would Clara classify this reply the same way today?".

[inference] Two distinct experiments, don't conflate them: (1) decision replay — feed the recorded intents back and check the state machine agrees (cheap, deterministic, no LLM, no cost); (2) comprehension replay — feed the resident's raw text back through the real classifier and check the derived intent matches what was recorded (needs the subscription eval runner, scripts/lib/subscription-eval-runner.ts, never metered keys — harness skill §6).

(e) Judge / scorecard — what "broke" means

REUSE the shape, not a new grader. Existing verdict vocabularies: production-replay.ts's

PASS/MISMATCH table with sagaStateToOutcomeCategory() mapping `EXECUTED|NTV_RECEIVED|PREPARE_FAILED|

HUMAN_REVIEW_PENDING|OPENED|OFFER_PREPARED|OUTREACH_ACTIVE|INTENT_TO_RENEW|INTENT_TO_VACATE → outcome categories; the replay suite's named invariants; scripts/option-d-proof/{checks,report}.ts's four acceptance families (invariants / equivalence / status-table / stability) with a Markdown + JSON twin report; e2e-permutation-matrix.ts's per-cell readiness.md. [inference] Option D's four families map cleanly onto renewals: invariants (one saga per tenancy per cycle, ≤1 page per lease+reason, every start inside the property's own window), equivalence (replayed outcome == recorded outcome), status table (every AppFolio renewal_summary status maps through the declared enum, src/lib/integrations/appfolio/status-enum.ts), stability (re-run is byte-identical). Borrow option-d-proof/report.ts` rather than writing a fifth reporter.

(f) Mutation control

Mostly MISSING for renewals. 300+ controls in scripts/mutation-controls/; the only renewal one is renewal-replay-path-filter.control.ts, which proves the path filter reddens — nothing proves the replay's invariants redden. Precedents to copy: option-d-ddb-sandbox-guard.control.ts (plants propflow-prod, requires a throw), portfolio-harness-falsifiable.control.ts, hitl-harness-coverage.control.ts. Enforcement is scripts/check-mutation-control.ts at pre-push. Note: Fede's lab is local, no CI, no PR — so pre-push enforcement won't fire, but the skill's rule that a check with no control doesn't count still applies, and the 2026-09-19 lesson ("two 'engine defects' were grader errors that controls exposed") is exactly the failure mode a 12-month replay is prone to.


3. Proposed PR chain

Each ≤300 lines, one concern, mergeable dark on its own. Atlas rule: everything extends scripts/renewal-harness/ or testing-harness/; **no new top-level *-harness dir is justified. Fede said local lab, no CI/PR review — but these still land as commits so the lab is reproducible. [inference] the effort numbers are mine.**

# Concern Extends Rough effort
1 Capture: a read-only Camellia renewal corpus exporter. Widen production-replay.ts's DDB reader into scripts/renewal-harness/replay/capture-camellia.ts — --from 2025-09-01 --to 2026-12-31, emits sagas + event logs + the _audit-camellia-renewals.ts AppFolio join to a scratch dir outside the repo (assertOutputDirOutsideRepo from evals/replay-harness/goldmine/lib.ts already exists). Nothing committed scripts/renewal-harness/replay/ ~250 ln, half a day
2 Scrub + fixture envelope. Run the corpus through scripts/promise-harness/scrub.ts into the HarnessFixture envelope (harness skill §1) with sourceCaseType: 'prod_pull', contentHash, scrub.verifiedBy. Only the scrubbed set may ever be committed same ~200 ln, half a day
3 Widen the history export bounds. scripts/temporal/export-recent-histories.ts currently caps at --limit=10 --closed-within-days=30. Add --from/--to and a higher ceiling; scrub with the existing scrub-history-fixture.ts scripts/temporal/ ~80 ln, 2 hours
4 Real-corpus driver on the hermetic rig. Teach portfolio.ts to seed from a captured corpus file instead of only the invented book (keep the synthetic book — it's the CI job's input and must not regress). Same in-memory Dynamo, same doubles, same empty-seed tmpdir scripts/renewal-harness/replay/ ~280 ln, 1 day
5 Stretch the clock to 12 months. REPLAY_DAYS 30 → parameterised; walk 2025-09-01 → 2026-12-31. Guard the runtime (the 30-day walk is single-fork with a 240s timeout — a 480-day walk needs measuring and probably day-batching) same ~150 ln, 1 day incl. tuning
6 Workflow-level arm on time-skipping. A TestWorkflowEnvironment runner that takes one captured renewal and replays its signal sequence with time-skipped timers, so the 14-day countersign and the reminder cadence are really exercised. Closes the PoC's own documented TODO testing-harness/scenarios/ or src/__tests__/ ~250 ln, 1 day
7 AppFolio read fixtures. Promote doubles.ts's makePmsReadClient() into a recorded-fixture reader replaying the captured renewal_summary / charge_detail rows, so eligibility decisions run on the same numbers the real decision saw. Writes keep going to SandboxAppfolioPMSWriter + the sandbox ledger scripts/renewal-harness/replay/ ~250 ln, 1 day
8 Scorecard + report. Port Option D's four acceptance families and report.ts's Markdown+JSON twin. Output: per-renewal PASS / MISMATCH / NOT-REPLAYABLE with a reason same ~280 ln, 1 day
9 Mutation controls. Two minimum: plant a window regression and require the "inside the property's own window" invariant to redden; plant a propflow-prod table name and require the sandbox guard to throw scripts/mutation-controls/ ~120 ln, half a day
10 (optional) Tenant comprehension arm. Replay raw resident replies through the real classifier via scripts/lib/subscription-eval-runner.ts and diff derived intent vs recorded intent. Subscription runner only same ~250 ln, 1 day

[inference] Critical path to a first useful answer is 1 → 2 → 4 → 8 (~3 days). 5, 6, 7 deepen it; 9 keeps it honest; 10 is a separate question.


4. Risks

PII in fixtures. The atlas found 4 real-PII fixtures with zero scrubbing live on 2026-08-26 (scripts/after-call-harness, evals/gauntlet, data/eval, plus a scoped gap in Cerberus's own scanner). A 12-month Camellia pull is the highest-PII corpus anyone in this repo has attempted: real names, phones, emails, exact rents, exact balances, exact late-payment dates. Mitigations that already exist: renewal-eligibility-replay.ts's own header rule (never commit a real charge_detail/rent_roll pull), assertOutputDirOutsideRepo, promise-harness/scrub.ts full-replace, the no-real-names.drift.test.ts + manifest-count.drift.test.ts tripwires, and funnel-context-no-real-names.control.ts as a control precedent. [inference] Default the capture output to a path outside the repo and make committing any fixture a separate, explicit PR — the exposure here is not "a name leaks", it is a year of one customer's residents' financial history in a git history that cannot be rewritten.

AppFolio write safety. Good news: the fences are real and structural, not conventions. SandboxAppfolioPMSWriter cannot reach AppFolio (import fence + drift test + a test asserting zero l4-core calls across every op), writer-registry.ts fails closed for a test property, and set-property-pms-sandbox-mode.ts refuses any property without isTest === true. The live risk is the one the repo already paid: Willows was seeded pointing at JP's REAL AppFolio database and willows-live-run.ts dispatched a real "prepare renewal offer" into it for a fake tenant (Fede, 2026-09-03 — the reason set-property-pms-sandbox-mode.ts exists). [inference] For a mocked-AppFolio lab, don't rely on the writer at all: keep setup-env.ts's posture of deleting the credentials from the environment, so a leak is a loud failure rather than a sandboxed one. Also wire HARNESS_SUPPRESS_OUTBOUND=1 (skill §7 — quality-replay/ has a real near-miss where one outbound call slipped past interception and only that switch caught it).

Temporal determinism replaying old histories against new code. This is the sharpest technical risk and the repo has already been bitten twice. (i) 2026-05-27: PR #1214's unguarded recordWorkflowPhase froze three migrated Camellia renewals for 13 days with no alert — the reason the replay-corpus gate exists. (ii) Right now, scripts/canary/replay-verifier.ts is chronically red and marked continue-on-error on three open Camellia *-migrated workflows carrying letter_mms_in_workflow_v1 non-determinism. (iii) Fixtures get retired, not fixed, when their cohort drains: fixtures/temporal-histories/retired/ holds wedged-migrated-precutover-{a,b}.json (ADR-0108 D5 dropped their replay compatibility; the pre-2026-05-20 cutover cohort passed the 60-day run timeout) and postcutover-recovered-declined.json (2026-06-09, predates defer_outreach_to_send_window_v1 / renewal_transient_prepare_resilience_v1, retired 2026-09-05 when both patched() branches became unconditional). [inference] Concretely: Camellia renewals from 2025-09 through roughly 2026-05 will NOT replay against today's workflow code, by deliberate design decisions already taken. A 12-month lab must therefore classify each renewal as REPLAYABLE / NOT-REPLAYABLE-BY-DESIGN / DIVERGED, and treat a NOT-REPLAYABLE count as information rather than failure — otherwise the first run reports a catastrophe that is actually ADR-0108 D5 working as intended. The scan-level walk (§2c option 3) has no such problem: it doesn't replay histories, it re-runs the scan, so it covers the full 12 months.

Cost. The hermetic rig is free: no AWS, no Temporal, no LLM, and the existing 30-day CI run is 2m03s–2m15s across the last 8 runs. [inference] a 480-day walk at the same per-day cost is ~35 minutes single-fork — acceptable locally, and the reason day-batching is in PR 5. Real costs appear only if the tenant comprehension arm (PR 10) is built, and that must run on scripts/lib/subscription-eval-runner.ts — the metered-spend-guard blocks metered-key paths by design (skill §6). The prod DDB capture is reads only. Local start-dev is free. TestWorkflowEnvironment is free.

One more, unasked: testing-harness/ is the piece closest to what Fede wants and it is half-orphaned — its README still says the workflow file "moves to ../src/ in Phase 4", a migration note the atlas flagged on 2026-08-26 as never resolved, and its planned .github/workflows/temporal-scenarios.yml never landed. Its activities are stubs. [inference] Before building on it, check whether testing-harness/src/workflows/renewal-workflow.ts still matches src/lib/temporal/workflows/renewal-workflow.ts — production-replay.ts imports the real one from ../../src/lib/temporal/workflows/, so the duplicate under testing-harness/src/ may be dead code. If it is, PR 0 is deleting it.


5. Spare Twilio numbers (list only — nothing bought, nothing reconfigured)

From config/phone-registry.json. [inference] the "spare for lab" column is my read, not a field in the file.

Number Kind Bound to Spare for a lab?
+1 833 408 5783 property-line appfolio-45, SMS webhook → the PR preview deployment (scripts/claim-preview.sh flips it per PR) No — the nightly harness pages it, and its 7-in-7 touch budget already exhausts (the chronic flap)
+1 844 285 3526 property-line appfolio-45 — the line every bench/harness and human tester dials; also AppFolio's MFA OTP target No — never repoint
+1 720 637 1205 tester (tenant) appfolio-45, registered as AppFolio test tenant occupancy appfolio:1829 Best fit for a renewal lab: already resolves as a Willows resident
+1 720 594 2061 tester (prospect) appfolio-45, "Promise bench caller" Usable
+1 720 679 5566 tester (handyman) appfolio-45 Off-role
+1 720 330 3727 tester (pm) appfolio-45, webhook → /api/sim/front-gate; EL import pending Usable as the PM side
+1 720 586 4069 tester (unknown) appfolio-45 — intentionally unresolved, must carry zero IdentityClaims between runs (scripts/reset-unknown-caller.ts) Don't touch
+1 720 212 2112 tester (tenant-recipient) maintenance-eval bench tenant (Marcus Johnson, appfolio-45/TEST-204) Off-role
+1 720 263 0905 / +1 720 259 8118 tester (robo-vendor) vendor QA Off-role
+1 970 715 6952 tester (hunt-chain-fake-person) bought 2026-09-20, Mancos CO Off-role
+1 970 615 5171 hunt-line bought 2026-09-19, Montrose CO, $1.15/mo Idle [inference]
+1 833 854 8998 prototype call-center-menu rig; toll-free verification REJECTED, cannot carry SMS Voice-only, idle
+1 720 548 2009 / +1 720 802 0338 prototype Western Slope maintenance bridges (EN / ES), static TwiML In use
+1 720 807 1724 property-line fairhaven-homes portfolio bench, robot callers only, bound to the SANDBOX Triage Bench, in use
+1 719 601 1047 property-line yale-sandbox Sandbox, in use
+1 720 606 4705 throwaway EL verified caller id only, not on the Twilio account, owner unknown No
+1 844 510 1007 (Camellia), +1 720 734 8637 (Yale), +1 970 822 0641 (Western Slope), +1 970 434 7000 (WS customer-forwarded main line) — REAL CUSTOMERS Never

[inference] For a mocked-AppFolio local lab you need no phone number at all — doubles.ts stands in for the Twilio HTTP call and records every send. Numbers only matter if the lab graduates to live comms, and that is a separate Fede go under the external-writes gate.

Customer context: Western Slope, Situs, and the org model

Source: scratchpad/report-customer-context.md

Renewals for a centralized team — Western Slope, Situs, and what our org model requires

Read-only research, 2026-09-23. Sources: #transcripts raw per-speaker threads (channel C0BE1NFTA0K), the Clara onboarding playbook + WS knowledge gold set, Gera's org-architecture page, the WS go-live tracker, the repo's AppFolio skill + scraped AppFolio help KB.


1. Western Slope's renewal process today, as they described it

Who they are. Western Slope Property Management, Grand Junction CO — a true third-party manager, ~250 doors / 70 properties in our org record, scattered single-family and small multifamily. Situs Group owns 25% of them (Eileen O'Malley, 2026-09-01: Jay at Western Slope "is the model third-party operator — accounting and leasing dialed"). People: Jason Fish and Jay Taylor (org_admin), leasing agents Kat and Bev, Cheryl. Fede's word on the call, 2026-09-04: "And it's centralized, well…" — WS is the customer that forced the centralized model.

They do renewals out of AppFolio's LEASING flow, not its RENEWALS flow. This is the single most load-bearing fact.

JasonFish (2026-09-09 kickoff): "we've been using the AppFolio leasing function for our leases for a while… we also use the leasing function for our renewals, as opposed to the renewals function, right? So we're utilizing a handful of amendments in the leasing function, and in doing so, we've, like, bypassed the ability to leverage some of the… Optionality, like, AppFolio and renewals function allows me to send a letter that's like, hey, if you want a 6-month renewal, it's this, a 9-month, it's this, a 12-month, it's this, and forces us to, like, work that whole thing out with the tenant."

JasonFish (same call): "It also means some of the reporting functionality that relies on pulling a renewal report as opposed to a leasing report isn't working the way we need."

JasonFish: "with AppFolio, we weren't using the renewal functions to the best it could be, and, like, I'm assuming that you guys might require some setup from us… if you're like, hey, in order for it to do renewals, you're gonna have to get set up on this, great, we'll get set up on that, all for it."

JasonFish: "The ability to start over from scratch is, like, my dream… a lot of the stuff that the AppFolio integration team put in there is some of the stuff that we're trying to clean up now, years later."

So: renewals today = a lease + hand-picked amendments/addenda sent through Send Lease or Addendum, no renewal-offer records, no multi-term option letter, and renewal reporting that doesn't work. Fede's reply, same call: "I think it helps if it's, like, if we're consistent and you guys have, like, one process for your whole portfolio, and, like, maybe one template, like, one addendum… because sometimes you might have, like, 10 different templates, and it's like, oh, which one is it?"

Lease terms and notice (from the client-confirmed knowledge gold set, approved by Fede 2026-09-15):

  • Standard term 12 months; Ember Estates runs a promotion of 14–18 month terms (client correction: "at ember estates for our promotional offering we are doing longer term leases of 14 18 months").
  • Month-to-month after the lease ends adds $150/month on top of rent (knowledge.lease-length, verdict Wrong only on the Ember detail).
  • MTM notice = 30 days' written notice, client-confirmed (knowledge.mtm-notice-period). Our draft had flagged that their lease template library holds five versions that disagree on this; the go-live tracker records the conflict as "month-to-month notice 21 vs 30 days across five lease templates." Jason's answer settled it at 30 days, matching three of the five templates. Their template library is inconsistent — that is a real renewals blocker.
  • Notice-to-vacate already reaches Clara: JasonFish (2026-09-09) "I saw we got a notice to vacate, uh, last night, and Clara was CC'd on it, which is cool."
  • Colorado deposit return within 60 days of move-out (confirmed Correct in the gold set).
  • No rent-increase policy was stated by WS in any transcript. They never gave a standard increase %, a rent floor, or a term-steering rule. Do not assume one.

Notably absent from the knowledge base by decision. The KB pass-2 row on the tracker: 11 items were cut, including "renewals" and "tenant money rules" — renewal policy is deliberately not client knowledge today.

Why renewals come BEFORE maintenance — the MCC contract. Their after-hours maintenance answering service is MCC, reached today by pressing 9 on their RingCentral tree, which simply forwards to MCC.

JasonFish (2026-09-09): "the fact that we can leave the MCC thing as it is… if that's a good way for us to move forward here without having to rip the maintenance band-aid today, then… That makes that very easy for me. We are going to have to pay for MCC through April 30th of 2027, no matter what. Um, so, though I'd like to get rid of it long before then, I think there's a chance I might have to pay for it until then, and that's fine. But it means that we've got time to work on other stuff. And so if we're going to focus leasing and then renewals and then maintenance, I think that's fine."

Fede (2026-09-09): "seems like you guys prefer to do renewals before maintenance?"

Restated internally on 2026-09-23 — Fede to Sean: "they wanted renewals first, and then… Maintenance last, remember in the last call, because of a contract they have… the Maintenance phone line, like, they already have that contract till next year… so it would be more valuable to the renewals." Sean's written proposal still says leasing → maintenance → renewals; the client's stated order wins.

Jason's own framing of the prize: "renewals is gonna be a big piece" (2026-09-09). On 2026-09-16 he set the sequence himself: "Okay, then we take over the rest of the lease. We'll start with that, and then we can move on to leases and renewals."

AppFolio tier: they moved to Plus during onboarding — JasonFish, 2026-09-09: "today's Wednesday, right? We took a demo with… or we had a conversation with AppFolio about Plus, signed a DocuSign this morning, got the API key a little bit ago. I mean, we're moving."

2. What they asked Clara to do

Nothing about renewals was demoed to them working — Jason, 2026-09-09: "I didn't really check the renewal… thing, you know, like, hey, I'd like to renew this property. I didn't test that either… I know renewal stuff was a little bit later in your pipeline of, like, onboarding." Fede: "that's not hooked up to the prototype right now."

What was promised to them is Sean's pitch on the 2026-09-04 demo call:

Sean: "we have a renewal workflow where we proactively automate renewals, like… we like to reach out, like, 90 days before the lease is expiring, and we give them a renewal offer." Sean: "you can see these are the renewals we have upcoming for the month of September." Sean: "we do the proactive outreach, right? And then if someone says they're not renewing…" JasonFish, reading back the integration: "there's outputs and inputs that your model is giving into AppFolio to manage guest cards, send renewals out in the renewal module…"

So Jason's mental model is explicitly that Clara writes into AppFolio's renewal module — the module they don't currently use. Their ask, in their words: tell us the AppFolio setup best practices and we'll adopt them.

Timing they expect: fast. Jason: "we want to break the record for onboarding"; "we're small and nimble… I'd love to figure out how we could speedrun this." They are a design partner ("we're used to working as a design partner") and expect to be told what to change on their side.

Where renewals actually stand for WS today (go-live tracker, read 2026-09-23):

  • Org org_c42d9150-b9ad-46f8-8f5f-b0fe56bacf38 "Western Slope Property Management", type centralized, purpose customer. Jay Taylor's org_admin invite accepted 2026-09-11.
  • Stored modules read 2026-09-23: leasing ON; renewals, maintenance, collections, lease-billing OFF. (Fede's call 2026-09-10: "leasing.")
  • Open tracker row: "Routing: module check before any specialist (renewals had none); WS = leasing only" — the renewals specialist had no check for whether the renewals module was even on before it engaged, so WS calls could be misrouted into it. PRs #11124, #11306; remaining ungated legs in issue #11307. This is a live correctness bug in the renewals path.
  • Related open row: "Tenants reaching a leasing-only line: decision" — residents already reach Clara on the forwarded main line and are recognized org-wide; the maintenance capability is granted on identity.type==='tenant' with no read of Property.capabilityStage.maintenance (='off' for WS). Same class of gap will apply to renewal asks from residents.
  • Internal target from the 2026-09-23 standup: renewals is next week's work, Fede owning renewals + leasing while Gera runs Citus onboarding.

3. Situs Group (and Hugo / MMG)

Situs Real Estate Corp, Denver — owner-operator + syndicator, ~550 units B/C class in Lakewood/Arvada/Denver, GPs Hugo and Noel; Eileen O'Malley was VP of Operations (leasing, maintenance, turnovers, renewals) and has since left — the 2026-09-01 whiteboard deep-dive was her last week, which is why her account is the record.

Renewals is Eileen's #1 ROI pick, and there is no process:

"The one that I think would get the hugest ROI… would be the renewals." She discovered there is no renewal process — it's Miriam nagging people by call/text/email. AppFolio's auto-email tells tenants to respond by the lease-end date, contradicting the lease's required 30-day notice. A renewal incentive tracker was discovered only six weeks before the session, explaining rents that "must be an error"; no checks and balances beyond one manually updated sheet. Non-renewal outcomes are not entered — the vacancy tracker hadn't been fed renewal outcomes for ~3 months and nobody knew. Her wish: predictive renewal pricing off the pro forma + market comps + unit condition, with multi-term option menus like Yardi — e.g. 1-month at a punitive rate, 12-month standard, and penalize lease-ends landing in Nov–Feb. She "deliberately paused building a new renewal process once PropFlow entered the picture — better that change 'comes from you' than from her." Fede's answer on the call: PropFlow already runs autonomous renewals at 90 days — set a building policy, the agent generates and sends offers, holds back exceptions (evictions, chronic late payers, via ledger access), nags every two weeks. Eileen: "I would love that."

Rent roll and vacancy data are "almost never correct" (move-ins unrecorded, leases uncountersigned, transfers papered later) — Eileen: "we're gonna live or die by the sword" on data hygiene.

Sequencing for Situs/MMG is NOT renewals-first. 2026-09-18 call with Hugo and Eileen: Clara replaces MCC after-hours, and "Modules turn on incrementally: leasing first, then maintenance, then renewals. The phone-line cutover is the LAST step." Sean on that call: "we'll start with the leasing, and then when you guys are ready, we can turn on maintenance, and then when it's ready, we can turn on renewal." Gera's arch page also records decision D4 "Maintenance line first for Situs" as Fede's, no gate. Inference: the two documents conflict on whether Situs starts with the maintenance line or leasing after-hours; worth confirming with Fede before planning renewals work against Situs.

Hugo's own demo (2026-08-25) shows what he expects from renewal automation — the guardrail, in his words:

"if they try to negotiate, the AI won't do that, it'll say, we will talk to the manager, and then you'll get an email saying, hey, the tenant will renew if, you know, they offer X, and then you can make the judgment call." "if you go to the upcoming renewals, so this is showing what we have upcoming… and then you can filter by, like, oh, if I want to see, like, what are my August… September…"

Hugo/MMG runs one main number with no per-property trees, so a renewals pilot cannot be scoped to a subset of properties — the funnel is by intent only. Same constraint as WS.

4. What our org model requires for renewals (Gera's portfolio architecture, docs.propflowai.co/a/portfolio-architecture-how)

The model, in its own words: "The org is the customer, the contract and the wall." A building is a room inside it. Figure 16 draws Western Slope explicitly: "WESTERN SLOPE — ONE ORG, SIXTEEN HOMES, EVERYTHING ON THE ORG … The Western Slope leasing line: one number, all homes. One calendar: two leasing people. Policies · fees: set once, up here." (Sixteen homes was the Sep-7 snapshot; the production org now carries 70 properties.)

What that requires of a renewals build:

  1. Renewal policy is an org-level attachment, with a property override. Capabilities are <node>/ATTACH#<kind>#<slot>#<startedAt> rows on ORG# or PROP#, read by one resolver that walks building → org and answers nearest-wins, with a lock the org can set. Rule as written: "Attach to the org what the whole customer shares; attach to the building what only that building uses; a building without its own answer inherits the org's" and "nearest-wins: the building's attachment beats the org's." Decision D11: "None — nearest-wins with the company's lock available is 'portfolio wins' when the company chooses it and 'the building knows better' when it does not." Decision D2: every existing key ships as a nearest-wins PARAMETER with today's constant as absentMeans; the registry classes keys POLICY / PARAMETER / IDENTITY. So: rent-increase rules, term menus, notice windows, the 90-day trigger, the nag cadence and the exception list are org-level parameter keys with a property override, never per-building-only settings. Fede's standing rulings agree (tour length is company-level; no per-building coverage; property overrides org on knowledge conflicts unless the company value is locked).
  2. Renewal outreach goes out on the org's channels. ADDR#<channel>#<address>/HEAD is the platform-unique claim that a number or mailbox belongs to one node; for WS the line, the mailbox and the calendar all hang on the org. Renewal email therefore sends from the org mailbox (leasing@westernslopepm.com today, with the standing rule: org mailbox is the default, a property may override, nearest wins) and renewal texts/calls from the single org leasing line. There is no per-property renewal sender at a centralized customer, and creating 70 of them is explicitly what the model rejects — Sean, 2026-09-17: "if it's a centralized model, like Western or Citus, you only need one inbox… they have 70 properties, you're not gonna create 70."
  3. Escalations follow escalation.owner, classed DYNAMIC (nearest-wins). Decision D2 reverses the Sep-1 identity classing specifically so escalation ownership inherits org → property. For renewals that means a negotiation hand-off (Hugo's "we will talk to the manager") resolves to the org's named owner unless a property names its own. Fede's standing rule routes escalations to Property.propertyEmail — at a centralized customer that value must resolve through the org attachment, not be typed per home.
  4. Roles. "Owner" is retired as a bare word in favour of three: org_admin (Jay Taylor at WS; the only role that may hand out manage_roles; at least one live admin always, the last of whom cannot self-remove), property owner (grant widened Sep 8 to reports · financials · occupancy · work_orders · documents for the buildings they own, with conversations grantable and OFF by default for tenant privacy), and PMS credential holder. Implication for renewals: a property owner can see renewal/occupancy/financial outcomes for their own homes but not the resident conversations unless granted. A renewals dashboard shown to owners must be built on the report grant, not on conversations.
  5. Membership is a PersonRole scoped to an org, a group or a building. Leasing agents Kat and Bev hold org-scoped roles, so "Leasing agents see just leasing and renewals" (Fede, 2026-09-17) — renewals is in the leasing role's visibility, org-wide, not per home.
  6. The known gaps the bench already measures. The isolation gauntlet records T6 CANNOT-EXPRESS — "The one resolver returns a building id; the customer is only ever derived through a building" — and T14 FAILS: a company-level line "silently becomes one home's line, chosen by roster order." T9 CANNOT-EXPRESS: calendars are still building-attached. So today an org-level renewal action still has to borrow a building to resolve, and org-level line/calendar attachment is not shipped. Inference: a renewals build that assumes an org-level sender or an org-level policy row exists today would be building against rows that are still proposed; the tracker's own number was 113 of 179 ladder rows merged as of 2026-09-13.
  7. The umbrella tripwire (D9). WS's scattered homes sat under a single placeholder building, accepted "for Western Slope's first weeks with the tripwire, re-key once after 5a." Any renewal keyed off that placeholder inherits the re-key.

5. AppFolio renewal features, and the tier caveats

From the scraped AppFolio help KB (~/.claude/pms-knowledge-base/appfolio/help-articles/), article "AppFolio's Renewals Flow" and "FAQs: Lease Renewals":

  • Two different flows, and they are not interchangeable. Send Renewal Offer prepares a lease renewal offer (from the tenant page or the Renewals tab) and, if you allow tenants to give notice in the portal, emails you when the tenant declines. Send Lease or Addendum sends additional documents to sign. WS uses the second for renewals — that is why their renewal reports are empty.
  • Lease settings drive it. Default lease + renewal templates are applied at the property page; "Renew by Default" (auto-roll into a new term at expiry) is configured in Lease Settings — and it "does NOT apply to renewals using PDF form templates, it only applies to renewals using APM lease templates." WS's five-version template library is exactly the thing that breaks this.
  • Two entry points: the Lease Expiration Detail by Month report (filter out those who gave notice / are moving out / you won't renew) and the Renewal Index page (Leasing → Renewals), which tracks sent offers, what was offered, who signed, resend and reminder links, and countersigning.
  • Renewal notice letter — a cover letter, mailable or emailable before or with the offers; AppFolio auto-populates term lengths and rent amounts; resendable as many times as you like with different templates until acceptance.
  • Deadline to respond — shows in the resident portal and in reminder emails; AppFolio itself sends an automated reminder 7 days before the deadline; tracked in the Renewal Summary report. (Coordinate with Clara's cadence or residents get two nags.)
  • Multiple term offers — up to five offers plus one month-to-month option, mixing term lengths and rents; the MTM offer can carry an additional fee; rent can be raised by a set % or $, tracked in the Renewal Summary report for rent-control compliance. This is the exact capability Jason said they bypassed and Eileen asked for.
  • Autoroll + proration — monthly charges editable inside the renewal flow; the prorated charge is created when the renewal is countersigned; autoroll mid-month credits back the overcharge.
  • API surface (repo skill /Users/federicochapa/.claude/propflowai/.claude/skills/appfolio/SKILL.md): lease fields LeaseFrom, LeaseTo, LastLeaseRenewal, LastRentIncrease, NextRentIncrease, NextRentAdjustment; endpoints include lease_expirations, renewals, lease_renewals, tenant_renewal(s), notices; tenant_tickler carries lifecycle events incl. "renewal signed"; property_directory carries per-property renewal fees.

Tier caveats (article "AppFolio Property Manager Plus Features"):

  • The AppFolio Reporting API is Plus-only. So is AppFolio Report Connection, Custom Fields, Custom User Roles, Audit Center, Affordable Housing (LIHTC / PBS8, income certifications, TRACS), Bulk Pricing, Bulk Move Out, Realm-X Flows, Leasing Metrics Dashboard, Performance Insights.
  • "Bulk Lease Renewals Workflow" — prepare and send lease renewals in bulk by property or property group — is Plus-only. That matters for a centralized team: bulk-by-property-group is the AppFolio-native shape of "one team renews 70 properties."
  • WS is on Plus (signed 2026-09-09), which also explains their affordable-housing interest (Courtyard, Lincoln) and gives us the Reporting API. Inference, not confirmed: Situs/MMG's tier is not stated in any source I read — if they are not on Plus, neither the Reporting API nor bulk renewals is available to them, which would change the Situs renewals design.
  • The base renewals flow itself (Send Renewal Offer, Renewal Index, notice letter, multi-term offers, autoroll) is not listed as Plus-exclusive — it is standard.

6. Western Slope leases ending in the next 90 days

No 90-day expiration pull found. What does exist, from the go-live tracker (read 2026-09-23):

  • The 2026-09-13 real AppFolio import (PMS_IMPORT#org_c42d9150…/ACCOUNT#appfolio#westernslopepm#17a5) wrote tenant: 142, lease: 142 in its "People and leases" stage.
  • 356 occupancy rows across 55 of WS's 70 properties (PERSON#/OCCUPANCY# via the phone-index GSI).

No file, report or tracker row I found breaks those 142 leases down by LeaseTo. Getting the number is a single read-only lease_expirations or rent_roll call against westernslopepm.appfolio.com with the Reporting API key (Plus, provisioned 2026-09-09) — not run here. Do not quote a 90-day figure until that pull exists.

A dedicated read-only filesystem sweep (completed 2026-09-23) confirms this: propflowai/data/leases.json holds test data only, propflowai/scripts/data/western-slope-proto-seed.json holds unit/listing data with no lease dates, and agents/007/org-cleanup-2026-09-08/ holds org profiles and unit refs with no lease records. The 142 imported leases live in the production DynamoDB table, never exported locally. The only Western Slope renewal-adjacent doc is the general propflowai/docs/runbooks/renewal-orchestration.md, which is not WS-specific.


The one-line answer to the question asked

"Renewals for a centralized team" at Western Slope means: one leasing team of two people, working one queue of ~142 leases across 70 scattered properties, off one phone line, one mailbox and one calendar that all hang on the org — with renewal policy set once at the company level and overridden only where a single home differs. It also means fixing their AppFolio setup first: they renew through the leasing flow with ad-hoc amendments, their five lease templates disagree on the month-to-month notice, and their renewal reports are empty. The client put renewals ahead of maintenance for one concrete reason — they are locked into the MCC maintenance answering contract through April 30, 2027, so there is no money to save by automating maintenance first.

Lease volume (read-only DDB query)

Source: scratchpad/report-ws-lease-volume.md

Lease volume (read-only DDB query, 2026-09-23)

Query: aws dynamodb query --table-name propflow-prod --index-name entityType-createdAt-index GSI3PK=OCC#<orgId> (TenantOccupancy rows, canonical lease locator) WS org: org_c42d9150-b9ad-46f8-8f5f-b0fe56bacf38 ; Camellia: org_jpco / property 1773625953462

Western Slope: 364 occupancy rows (350 active, 7 pending, 7 former)

  • lease-end by month: Oct-26 11, Nov-26 11, Dec-26 20, Jan-27 16, Feb-27 19, Mar-27 20
  • empty leaseEnd: 26; status=mtm rows: 0
  • ending within 90 days (through 2026-12-22): 43
  • leaseEnd already before 2026-09-23 but mostly still status=active: 62 (data-quality flag: no MTM rollover recorded)

Camellia: 187 rows (126 active, 61 former)

  • lease-end by month: Oct-26 7, Nov-26 13, Dec-26 8, Jan-27 7, Feb-27 10, Mar-27 8
  • empty leaseEnd: 23; mtm: 0
  • ending within 90 days: 26
  • leaseEnd before today: 22
Western Slope AppFolio setup audit

Source: scratchpad/report-ws-appfolio-setup.md

Western Slope vs Camellia — AppFolio renewals setup audit (read-only, 2026-09-23)

Method: read the existing deterministic capture at ~/appfolio-dumps/westernslopepm/ (taken 2026-09-07/08, before the 2026-09-09 Plus upgrade); no new browser session. Camellia facts from engineering records.

Side by side

AreaWestern SlopeCamellia
How renewals are doneMostly leasing flow + amendments (Jason). Renewal index (43 tracked occupancies, 09-08): 27 Eligible with no offer, 4 Out for signing, 3 Countersign pending, 9 completed via the renewal moduleRenewal module driven by PropFlow automation, autonomous since 2026-07-23 (policy: 2.0 months balance, 6 late payments, 25-day grace)
Lease templates5 usable: WSPM Lease - WSPM Sign; WSPM Lease - 2026; Northridge NEW LEASES; Northridge RENEWALS - Lease Term; Northridge RENEWALS - Month to Month; plus a live "Sample Lease Template". No single defaultOne renewal template ("Camellia Lease Renewal") separate from the new-lease template
MTM notice (from template bodies)2 of 5 say 21 days (both WSPM templates); 3 say 30 days (all Northridge). Client-confirmed policy: 30 daysNo conflict found
MTM fee$150/month verbatim in 4 of 5; the MTM-only template does not restate it in the captured sectionNot documented
Standard term12 months; Ember Estates 14–18 month promo has no template (applied by hand, inference)12 months
Renewal reportsPopulated at the roll-up level (renewal_summary: 19 offers Sep–Dec 2026; lease expiration detail: 91 rows). The thin part is the workflow-level Renewal Index (27 of 43 with no offer drafted)Not re-pulled
AppFolio reminder auto-emailsLikely not firing for most leases because no offer object exists (inference)Not re-verified
Property groups (Plus bulk renewals)Already in use ("1240 Elm Apartments LLC", "ALL Orchard Mesa 81503"); bulk-renewal feature itself unverified (capture predates Plus)n/a, single property
Owner approval per propertyNot found; unverifiedn/a

MTM handling

AppFolio's Lease Expiration Detail buckets 89 of ~91 rows as month-to-month (09-08). PropFlow's 62 expired-active rows come from PropFlow's own table; not reconciled in this audit.

Risks

  1. Two templates promise 21 days' MTM notice vs. the client's confirmed 30 — legal exposure if handed to a resident.
  2. MTM-only template does not show the $150 fee where the other four do.
  3. "Sample Lease Template" is live in the selectable list.
  4. 89-of-91 MTM rate is unusually high — real or a stale field.
  5. 27 of 43 renewal-eligible occupancies have no offer object, so the reminder path is dead for most.
  6. Per-property owner approval not visible either way.

Checklist for Western Slope

Must-do before turn-on:

  1. Fix both WSPM templates from 21 to 30 days' MTM notice.
  2. Consolidate to one canonical renewal template instead of 5.
  3. Confirm the $150 MTM fee appears in the MTM-only template.
  4. Create renewal-offer objects for all eligible leases (only 9 of 43 today) so reminders, the Renewal Index and 90-day outreach have something to work from.
  5. Give the Ember Estates 14–18 month promo a real template.

Nice-to-have:

  1. Retire or label the Sample Lease Template.
  2. Verify the 89 MTM units with Jason (real occupancy vs. data cleanup).
  3. Use the existing property groups for bulk renewal sends once Plus bulk is confirmed on.
  4. Ask directly about per-property owner-approval requirements.

What we change on our side

  • No single-constant template pattern (Camellia's JPCO_TEMPLATES); Western Slope needs per-property template resolution.
  • Keep the read-only write lock on westernslopepm as is.
  • Notice logic reads the per-template value (21 vs. 30), never a company constant.
  • Do not infer "not renewing" from an empty Renewal Index; 27 of 43 likely renewed through the leasing flow.

Sources: ~/appfolio-dumps/westernslopepm/reports/*.csv, renewals/index.json, settings_docs/templates/ (ids 1,2,3,5,15); appfolio-browser-agent/lib/{accountDefaults,readonlyLock,multiTenantConfig}.ts

Lab A: daily-scan replay (README)

Source: scratchpad/lab/README-lab-A.md

Lab A — replay Camellia's real renewals against today's renewal scan

Local lab. No CI, no PR. Built 2026-09-23 in the worktree /Users/federicochapa/.claude/propflowai-lab-renewal-replay-corpus, branch lab/renewal-replay-corpus (three commits, unpushed).

Nothing was written to production DynamoDB, AppFolio, Temporal Cloud or Slack. The capture is reads only; the replay runs with AWS, Temporal and Twilio credentials deleted from the environment, HARNESS_SUPPRESS_OUTBOUND=1, and a fake table name (propflow-replay-harness-does-not-exist). No LLM key of any kind is used — the lab has no LLM step.


1. Run it from a fresh shell

cd /Users/federicochapa/.claude/propflowai-lab-renewal-replay-corpus
export LAB=/private/tmp/claude-501/-Users-federicochapa-agents-004/268439c3-a006-4372-897c-3735a55d3990/scratchpad/lab

# 1) capture (READ-ONLY against prod DDB + the AppFolio HTTP API)
mkdir -p $LAB/corpus
npx tsx scripts/renewal-harness/replay/capture-camellia.ts --out $LAB/corpus
#   optional: --from 2025-09-01 --to 2026-12-31 --no-appfolio

# 2) scrub — the driver reads ONLY the twin this writes
npx tsx scripts/renewal-harness/replay/scrub-corpus.ts \
  --in $LAB/corpus --out $LAB/corpus/scrubbed

# 3) replay + scorecard
RENEWAL_CORPUS_DIR=$LAB/corpus/scrubbed RENEWAL_CORPUS_OUT=$LAB \
  npx vitest run --config scripts/renewal-harness/replay/corpus/vitest.config.ts
#   knobs: RENEWAL_CORPUS_EPOCH (default 2025-09-01T16:00:00.000Z — 09:00 America/Denver)
#          RENEWAL_CORPUS_DAYS  (default 487 = 2025-09-01 .. 2026-12-31 inclusive)
#          RENEWAL_CORPUS_STRIDE(default 1; scan every Nth simulated day)

# 4) the two mutation controls
npx tsx scripts/renewal-harness/replay/corpus/controls/window-regression.control.ts
npx tsx scripts/renewal-harness/replay/corpus/controls/prod-table-guard.control.ts

# 5) the synthetic CI path, unchanged, to prove nothing regressed
npm run test:harness:renewal-replay

Outputs: $LAB/scorecard.md and $LAB/scorecard.json. The corpus lives at $LAB/corpus (raw, never committed) and $LAB/corpus/scrubbed (the only thing the driver opens; loadCorpus refuses a directory whose manifest does not say scrubbed: true).

2. What was built

Everything extends scripts/renewal-harness/replay/. No new top-level harness dir.

Path What it is
scripts/renewal-harness/replay/capture-camellia.ts Read-only exporter: sagas, per-saga event logs, tenants/units/leases, the Person spine (persons + identity claims), occupancies, property + leasing settings + knowledge, and the AppFolio renewal_summary join. Refuses to write inside the repo.
scripts/renewal-harness/replay/scrub-corpus.ts Scrubbed twin, using scripts/promise-harness/scrub.ts. Key-aware: the full prose pass only on name/contact keys, a strict E.164-or-separated phone pass everywhere else.
scripts/renewal-harness/replay/corpus/load.ts The only door onto the scrubbed corpus.
.../corpus/seed-corpus.ts Seeds the hermetic rig from the real book (the synthetic portfolio.ts is untouched).
.../corpus/pms-read-fixture.ts The promoted makePmsReadClient() — AppFolio reads played back from the captured renewal_summary tape, keyed by unit name.
.../corpus/pms-fixture-holder.ts The seam the hoisted vi.mock reads; throws rather than answering a PMS question out of thin air.
.../corpus/scorecard.ts Pure classifier + invariants + Markdown/JSON twin, borrowing scripts/option-d-proof/report.ts's shape.
.../corpus/sandbox-guard.ts Preflight: fake table only, no AWS/Temporal/Twilio credential, kill switch on.
.../corpus/setup-env.ts ../setup-env.ts + the kill switch + the preflight, as vitest setupFiles.
.../corpus/run-corpus-replay.corpus.test.ts The driver.
.../corpus/vitest.config.ts Its own config, deliberately outside the CI job's include glob.
.../corpus/controls/*.control.ts The two mutation controls.

3. Corpus counts (capture of 2026-09-23T23:26Z, contentHash 2f48e2b2e032aaab…)

  • 165 renewal sagas in prod; 120 at Camellia (property 1773625953462)
  • 513 activity-log events across those 120 sagas
  • 149 tenant rows, 120 units, 103 leases, 133 persons, 187 occupancies
  • 115 AppFolio renewal_summary rows for AppFolio property 7; 63 of 120 sagas join to at least one

The AppFolio join works without the browser robot. It runs over the HTTP API (AppfolioClient.listAll('renewal_summary')) with the Camellia API clientId/clientSecret already on file in prod DDB. One request, one page, 115 rows.

By saga state: NTV_RECEIVED 28, EXECUTED 23, MIGRATED_TO_WORKFLOW 20, CANCELLED 18, HOLDOVER 9, PREPARE_FAILED 8, TRANSFERRED 7, OFFER_PREPARED 5, HUMAN_REVIEW_PENDING 1, INTENT_TO_VACATE 1.

By month the saga opened: 2026-04 8 · 2026-05 21 · 2026-06 10 · 2026-07 37 · 2026-08 15 · 2026-09 29.

By month of the tenancy's (current) lease end: 2026-04 1 · 05 2 · 06 3 · 07 4 · 08 4 · 09 14 · 10 6 · 11 8 · 12 8 · 2027-01 1 · 02 2 · 03 3 · 04 5 · 05 3 · 06 7 · 07 19 · 08 9 · 09 4 · 10 2 · 12 3.

Finding 0, and it reshapes the brief: there are no 12 months of Camellia renewals to replay. The oldest renewal saga in production opened 2026-04-30. The saga store covers just under five months, not a year. The 2025-09-01 → 2026-12-31 walk is still the right window (it is the window leases END in), but nothing before 2026-04-30 has a production counterpart to compare against.

4. Scorecard (487 simulated days, stride 1, 10.9 s)

171 renewals scored.

verdict count
REPLAYED-MATCH 4
DIVERGED 2
NOT-REPLAYABLE-BY-DESIGN 165
CRASHED 0

Invariants:

id check verdict evidence
i1 one cycle per tenancy per lease PASS 33 (tenancy, lease) pairs opened, 0 opened twice
i2 at most one PM page per (lease, reason) NOT RUN 0 pages delivered — see below
i3 every start inside the property's own window PASS 33 starts measured against Camellia's 90-day window, 0 outside
i4 no outbound outside quiet hours NOT RUN 0 sends — see below
i5 the lab did not crash PASS 0 lab-level errors

NOT RUN is not a pass. i2 and i4 are vacuous for a structural reason, not an oversight: this lab replays the daily SCAN, and the scan's job ends at opening a cycle. Outreach, PM pages and quiet-hours deferral all happen inside the renewal Temporal workflow, which is stood in for by a workflow starter that only reproduces workflow-id idempotency. The invariants are wired and their controls prove the machinery reddens; they have nothing to score until the workflow arm exists.

AppFolio tape: 353 hits / 126 misses (a miss = the walk asked about a unit with no renewal_summary row, recorded rather than silently answered clean).

5. Findings

F1 — the signer-email gate now catches renewals that used to fail at AppFolio prepare. (product finding, not a bug in current code.) The only two DIVERGED rows are both PREPARE_FAILED sagas that today's scan refuses to open because the Person spine holds no email for the signer:

  • saga_33866181-674f-44b0-8855-9c645c51cc5b, unit 204, lease end 2026-09-30, opened 2026-07-13, prod error PMS prepare timeout (10 minutes)
  • saga_67326c3c-0c63-4337-a1dc-20511b98f447, unit 409, lease end 2026-11-30, opened 2026-09-03, prod error PMS prepare failed: Activity task failed

Widening past what the walk could reach: 4 of the 8 PREPARE_FAILED sagas at Camellia belong to residents with no email claim on the spine (only phone + pms_external_id). The other four have an email and failed for other reasons (AppFolio field_value_sets returned 0 populated values, PMS_REJECTED httpStatus=422, two timeouts). Reproduction: scripts/renewal-harness/replay/corpus/ per §1, then jq '.rows[] | select(.verdict=="DIVERGED")' $LAB/scorecard.json.

F2 — the saga store does not go back a year (§3, Finding 0). Any claim about "12 months of renewals" at Camellia is, today, a claim about five.

F3 — saga.pmsRef still does not join to AppFolio, confirmed again on this pull. The only working cross-reference is unit name, which is why 57 of 120 sagas have no AppFolio row: the unit has no renewal_summary row at all, usually because the cycle predates the report's retention or the tenancy has since turned over.

F4 — 20 sagas sit in MIGRATED_TO_WORKFLOW. Their outcome is in the Temporal history, not the saga row, so a saga-level replay can say nothing about them. They are the largest single block of real renewals this lab cannot grade.

No product bug was found that needs fixing. The two divergences are the current code being stricter than the code that ran, in the direction that prevents the failure production actually hit. Nothing was changed in production renewal code.

6. Why 165 rows are NOT-REPLAYABLE-BY-DESIGN

Calling any of these a divergence would have manufactured a finding. Breakdown:

count cohort
47 the snapshot's lease end has already rolled forward past the walk — the expiring lease the saga was about is no longer in the book
41 the tenancy is archived (status=former) in the snapshot, so it is not seeded and the walk cannot see it
28 saga opened before the 2026-05-20 cutover (ADR-0108 D5 dropped replay compatibility for this cohort)
18 MIGRATED_TO_WORKFLOW (F4)
16 the walk opened a cycle after 2026-09-23, the day the corpus was captured — production has not reached that cycle yet
12 the snapshot carries no lease end for the tenancy
3 the snapshot's lease carries a notice-to-vacate, so the durable non-renewal stamp holds the tenancy the walk would otherwise have opened

The first, second, sixth and seventh rows are all the same root cause and it is the lab's biggest limitation: the corpus is a point-in-time snapshot of the book, not a time series. 100 of 165 unreplayable renewals are unreplayable because the book we seed is today's book, not the book as it stood on the day the saga opened.

7. Mutation controls (both green)

  • controls/window-regression.control.ts — builds two hand-written replay logs over the same book: one where every start is 30–89 days out, one where a single start is 120 days out against Camellia's 90-day property (the 2026-09-03 incident's exact shape). Requires i3 to PASS on the first and FAIL with exactly one offender on the second. PASSED.
  • controls/prod-table-guard.control.ts — requires the sandbox env to be accepted and four plants to be refused: DYNAMODB_TABLE_NAME=propflow-prod, a leftover AWS_ACCESS_KEY_ID, a leftover TEMPORAL_API_KEY, and HARNESS_SUPPRESS_OUTBOUND unset. PASSED, all four refused.

8. Runtime

stage wall clock
capture (prod DDB + AppFolio API) ~90 s
scrub ~3 s
487-day walk + scorecard 10.9 s (seed ~5 s, ~0.02 s per simulated day)
both controls < 2 s each
the synthetic CI suite, unchanged 5 files / 72 tests, all passing

Day-batching (RENEWAL_CORPUS_STRIDE) is implemented but was not needed. The pre-work estimate of ~35 minutes for a 480-day walk was wrong by two orders of magnitude — one property with 108 live tenancies is far cheaper per day than the synthetic three-property book.

9. What is left unbuilt

  1. A time-rewound book. The single highest-value next piece: reconstruct each tenancy's state as of the day its saga opened (AppFolio's PreviousLeaseStart / PreviousLeaseEnd carry the expiring lease, and the per-saga event log carries the transitions). That would move most of the 100 snapshot-cohort rows into a real verdict.
  2. The workflow arm. i2 and i4 stay NOT RUN until one captured renewal is driven through the real renewalWorkflow on TestWorkflowEnvironment time-skipping, so the 14-day countersign, the reminder cadence and quiet-hours deferral are really exercised. That was going to be PR 6 of the prior-art plan; it is not here.
  3. Balances. No AppFolio charge_detail was captured, so every tenancy is seeded with a clean balance and the lab cannot reproduce a hold that production applied because a resident owed rent. fetchRenewalFeatures says so in its provenance rather than pretending.
  4. Prod Temporal histories. scripts/temporal/export-recent-histories.ts is still bounded at --limit=10 --closed-within-days=30; the 12-month pull (prior-art PR 3) was not built, so the 20 MIGRATED_TO_WORKFLOW sagas remain ungraded.
  5. The comprehension arm. Replaying residents' raw replies through the real classifier (prior-art PR 10) — not attempted; it needs the subscription eval runner.
  6. A third control over the classifier itself (plant a fake DIVERGED and require the totals to move). The two controls cover the window invariant and the sandbox guard, not the verdict logic.
  7. Atlas registration. The harness skill's step 4 (a row on the Harness Atlas page) is not done — this is a local lab, and registering it is a publish, which is a separate call.
Lab B: workflow time-skip replay (README)

Source: scratchpad/lab/README-lab-B.md

Lab B — replaying a year of real Camellia renewals through today's workflow

Local lab, 2026-09-23. No CI, no PR. Worktree /Users/federicochapa/.claude/propflowai-lab-renewal-workflow-replay, branch lab/renewal-workflow-replay. Nothing pushed.

Every claim below is a row in scorecard-workflow.json or a line of the run log.


1. Commands from a fresh shell

# 1. the worktree (already created; recreate with)
cd /Users/federicochapa/.claude/propflowai
bash scripts/worktree-setup.sh lab/renewal-workflow-replay

cd /Users/federicochapa/.claude/propflowai-lab-renewal-workflow-replay

# 2. @temporalio/testing is NOT a repo dependency — install it
npm install --no-save --no-audit --no-fund @temporalio/testing@1.19.0

# 3. run. The corpus is cached under scratchpad/lab/corpus-b, so only the
#    FIRST run touches DynamoDB (read-only Query, never a write).
AWS_REGION=us-east-1 npx tsx testing-harness/scenarios/production-replay-timeskip.ts

#    flags
#      --recapture   re-pull the corpus from prod DDB
#      --limit N     replay only the first N sagas
#      --mutate      run the mutation control (plants a wrong recorded
#                    outcome on one MATCH row; must report DIVERGED)

Do not source .env.local for this. If you do, unset TEMPORAL_API_KEY TEMPORAL_ADDRESS TEMPORAL_NAMESPACE — src/lib/temporal/connection.ts routes to Temporal Cloud the instant TEMPORAL_API_KEY is set. The entrypoint deletes all three from process.env before importing anything, as a belt.

Outputs: scratchpad/lab/scorecard-workflow.md and .json.


2. What was built

file what
testing-harness/scenarios/production-replay-timeskip.ts entrypoint: capture → time-skipping replay → scorecard, plus the mutation control
testing-harness/scenarios/timeskip/config.ts property id, window, corpus paths, cutover date, the LIVE patched() ids + landing dates
testing-harness/scenarios/timeskip/capture.ts read-only prod pull, JSON-cached outside the repo
testing-harness/scenarios/timeskip/translate.ts recorded event → today's signal; recorded-outcome extraction
testing-harness/scenarios/timeskip/mocks.ts the 20 activities the workflow can call, all fakes
testing-harness/scenarios/timeskip/runner.ts one saga through TestWorkflowEnvironment.createTimeSkipping()
testing-harness/scenarios/timeskip/scorecard.ts MATCH / DIVERGED / NOT-REPLAYABLE-BY-DESIGN / CRASHED
testing-harness/scenarios/timeskip/report.ts Markdown + JSON twin, coverage section

testing-harness/src/workflows/renewal-workflow.ts does not exist. The whole testing-harness/src/ directory is gone from main — the suspected dead duplicate was already removed upstream, so nothing was deleted here. production-replay.ts imports the real workflow from ../../src/lib/temporal/workflows/; so does this lab.


3. The corpus

Source: propflow-prod, GSI4 renewal-saga-person-index (GSI4PK = 'RenewalSaga', ProjectionType ALL) — one partition holding 166 sagas total, of which 120 are Camellia (1773625953462). The frozen PoC did a full Scan of a 1.86 M-item / 3 GB table for the same answer.

Lease ends come from PK=PROP#1773625953462, SK begins_with LEASE# (157 leases). 66 sagas have a lease ending 2025-09-01 → 2026-12-31; those are the corpus. No saga in the corpus has a lease ending in 2025 — the earliest is 2026-05.

By month of lease end

lease end sagas
2026-05 1
2026-06 2
2026-07 5
2026-08 10
2026-09 18
2026-10 8
2026-11 11
2026-12 11

By saga state

MIGRATED_TO_WORKFLOW 19 · NTV_RECEIVED 14 · CANCELLED 14 · PREPARE_FAILED 7 · TRANSFERRED 6 · EXECUTED 4 · OFFER_PREPARED 2.

Opened by: system 29, cron 27, pm 10. 20 of 66 carry a workflowId. 6 of 66 opened before the 2026-05-20 cutover.

PII: the saga rows and event logs carry opaque ids (pers_…, lease_…, saga_…), an AppFolio occupancy ref and dates. No names, phones, emails or dollar amounts. The raw pull still lives only in scratchpad/lab/corpus-b/camellia-renewal-corpus.json and is not committed.


4. Scorecard

66 sagas. Two consecutive clean runs produced identical verdicts (0 rows differed), so the lab is stable.

verdict n
MATCH 30
DIVERGED 10
NOT-REPLAYABLE-BY-DESIGN 26
CRASHED 0
lease end MATCH DIVERGED NOT-REPLAYABLE CRASHED
2026-05 0 0 1 0
2026-06 0 0 2 0
2026-07 2 1 2 0
2026-08 6 3 1 0
2026-09 14 4 0 0
2026-10 1 2 5 0
2026-11 4 0 7 0
2026-12 3 0 8 0

NOT-REPLAYABLE breakdown: 16 still-open sagas (14 MIGRATED_TO_WORKFLOW, 2 OFFER_PREPARED) with no recorded terminal to compare; 6 pre-2026-05-20 cutover sagas (ADR-0108 D5 dropped that cohort's replay compatibility); 4 transferred, an outcome renewalWorkflow has no branch for (src/lib/data/types.ts: "never recorded by the Temporal workflow itself").


5. Top divergences

5.1 Replay artifact, not a live bug: prepare timeout moved from 10 minutes to 42 — 5 sagas, verified 2026-09-23 night, PR #11376 closed

recorded=prepare_failed, replayed=cancelled: saga_29a0481f-7e67…, saga_8f17a1af-a097…, saga_baf2ac4b-f224…, saga_03d19ece-7319…, saga_c77cd00b-1233….

What the event logs show, offsets from openedAt:

RenewalOpened                   0.0 min
workflow_phase_awaiting_pms     0.0 min
workflow_phase_escalated       10.0 min   <-- production escalated at TEN minutes
workflow_prepare_failed        10.0 min
RenewalSuccessorClaimStaked    ~30  min   <-- the prepare-retry cure claims it
RenewalSuperseded              ~30  min   <-- saga closed CANCELLED

Today's code waits RENEWAL_PREPARE_DEFAULT_TIMEOUT, which src/lib/domain/renewals/prepare-retry-budget.ts derives as ceil(AGENT_JOBS_RETRY_CAP_TOTAL_ATTEMPTS * RENEWAL_PREPARE_SQS_VISIBILITY_TIMEOUT_SECONDS / 60) = 42 minutes (the replay's own escalation string is PMS prepare timeout (42 minutes)). The prepare-retry cure still supersedes at ~30 minutes. So the supersede now lands INSIDE the prepare wait: the workflow observes pmsChanged → cancelled first and exits cancelled, where the same renewal used to exit prepare_failed at 10 minutes and be superseded afterwards.

Replay artifact, not a live bug (verified 2026-09-23 night, PR #11376 closed). The five sagas escalated under the old hand-set 10-minute timeout and were superseded afterwards; replaying their recorded supersede signals into today's 42-minute wait produces cancelled, but today's code cannot reach that state: PREPARE_FAILED has one production writer and it runs only after the workflow has already recorded its outcome. Reproduction: npx tsx testing-harness/scenarios/production-replay-timeskip.ts --limit 50, then read saga_29a0481f-7e67… in scorecard-workflow.json — its replayed activity log ends recordWorkflowOutcome {outcome: cancelled} at ~30 min with no escalateToPM. Lesson for the lab: recorded signals replayed across a timer change can mislead; check reachability against today's code before filing.

5.2 Real bug, queued as a live fix (PR #11378): outreach exhaustion records a phase but no outcome — 3 sagas

recorded=declined, replayed=none: saga_b21780ac-b954…, saga_8f533020-5d20…, saga_239995fa-fc9b….

Each runs the full ladder and ends at the quiet wrap-up. Virtual-time trace of saga_b21780ac-b954… (lease end 2026-10-31):

     0.00 min  loadSagaInitialState, pinRenewalScopeActivity,
               recordWorkflowPhase(awaiting_pms), dispatchPmsPrepare
     3.32 min  transitionSagaToOfferPrepared, resolveSendWindowDelayActivity,
               checkpoint(pre_first_contact), sendRenewalLetterMmsActivity,
               phase outreach, phase awaiting_response
  2883.32 min  (+48h)  checkpoint(pre_reminder_send), sendReminderSms step 1
 12963.32 min  (+9d)   checkpoint(pre_reminder_send), sendReminderSms step 2
 84969.02 min  (+59d)  checkpoint(pre_expiry_nudge), sendReminderSms step 30d
106569.02 min  (+74d)  checkpoint(pre_expiry_nudge), sendReminderSms step 15d
106569.02 min          recordWorkflowPhase(done)     <-- and nothing else

The run returns undefined and never calls recordWorkflowOutcome, so no workflow_<outcome> row would ever be written for it. The real renewals ended declined. Part of that gap is missing input (§7 — the decline evidence is not in the renewal event log), but "terminal with a phase and no outcome" is itself worth a look.

5.3 Two singletons

  • saga_e015ff8d-5078… — recorded escalated, replayed prepare_failed. Its only PMS fact in the log is a backfill_state_correction 30 days in; today's prepare timeout fires first. The real saga also had 2 workflow runs (escalated → intent_to_vacate); the lab drives one and compares the first.
  • saga_62367c92-e420… — recorded intent_to_vacate, replayed prepare_failed, same shape.

5.4 Crashes

None. 0 of 66 threw. No NondeterminismError, no unknown-signal rejection, no patch mismatch, no hang.


6. Temporal-specific findings

Two lab bugs, both fixed, both of a kind that silently fakes results — recorded because the same two will bite the next person:

  1. env.sleep() races the first workflow task. The test server only jumps to the next *timer*. A workflow mid-activity, or with an unprocessed workflow task, is not parked on one, so env.sleep(4.5 days) jumped the whole interval at once and the workflow then began its Phase-1 wait at t+4.5d — its own 42-minute timeout never fired. Symptom in the data: a run's FIRST activity stamped 6,504 minutes after t0. Fix: park() — query getSnapshot in a real-time loop until the activity log stops growing, then skip.
  2. handle.result() unlocks time skipping for its whole duration (node_modules/@temporalio/testing/lib/client.js:42). Starting it before the signals were delivered let the clock free-run: every recorded signal arrived "after the workflow closed". Fix: deliver all signals first with skipping locked (advanced only by explicit env.sleep), then await result().

Other Temporal notes:

  • @temporalio/testing is not a repo dependency, and despite what the prior-art report says, no test in src/__tests__/ uses createTimeSkipping — grep -rn createTimeSkipping src/__tests__ returns nothing. The ~12 "time-skipping" renewal tests actually mock the @temporalio/workflow sandbox primitives (condition, sleep, patched) and call the workflow body as a plain function. This lab is the first real TestWorkflowEnvironment use in the repo.
  • TestWorkflowEnvironment.currentTimeMs() is an async method, not a property. Context.current().info.scheduledTime inside an activity is NOT a usable virtual clock here — measured on this corpus it stayed pinned at the run's start while the 48h / 5d / lease-end timers fired.
  • Time base. The test server clock starts at wall-clock now and only moves forward, so each saga is replayed in virtual coordinates: t0 ≡ its real openedAt, signals at their real offsets, leaseEndIso shifted by the same constant. Every interval the workflow reasons about is preserved; only the absolute labels move. Verified: expiry nudges landed at lease-end −30d and −15d (59d / 74d after open for a 2026-08-02 saga with lease end 2026-10-31).
  • Timer matrix confirmed by observation, not assertion: prepare 42 min, reminder step 1 +48 h, step 2 +7 d (not the 5 d the field comment suggests), expiry nudges lease-end −30 d / −15 d.
  • History sizes were not measured. The test server does not expose them through the client surface this lab uses, and nothing here replays an exported prod history (that is what src/__tests__/renewal-workflow-replay-corpus.test.ts does). See §10.
  • Patch hygiene. No divergence in this run traced to a patched() marker. The scorecard annotates a DIVERGED row with the live patches that landed after the saga opened — renewal_scope_pin_v1..v4 (2026-09-20/21), renewal_infra_classifier_budget_v1 (2026-09-03), renewal_commitment_stated_phase_v1 / renewal_negotiating_intent_v1 (2026-08-01), countersign_reminder_requires_signature_v1 (2026-07-31) — but replaying from the top always reads them HOT, so that is a caveat on the comparison, not an observed fork. Proving the COLD side needs exported prod histories, not a fresh run.

7. Coverage gaps

Signals the year of real sagas never exercised: tenantIntent, deliveryStatus, pmAction, pmStatusOverride, and all three Updates (retryLetter, externalResolution, pmDecision). Only pmsChanged and pmsFailed fired.

Activities never called: sendOutreachSms, sendOutreachEmail, dispatchVoiceCall, startRenewalCountersignReminder, resolveRenewalCountersignReminder, escalateHeldRenewalActivity, classifyPrepareFailure, resolveLeaseEndIsoActivity, dispatchAppFolioWrite.

Workflow outcomes no real saga reproduced: intent_to_vacate, escalated, scope_moved.

Code paths therefore untested by this corpus: the entire tenant-response half of the workflow — the 14-day countersign wait, the countersign reminder cadence, commitment_stated, the negotiating branch, PM pause/resume and the manual terminals, the external-resolution and PM-decision wrap-ups, and the SMS / email / voice outreach ladder (Camellia's outreach goes out as the letter MMS).

The root cause is the data, not the lab. A saga's LOG#renewal#{id} stream records PMS facts and the workflow's own phase projections. It does not record the tenant's reply, the delivery receipt, or the PM's click — those live in the conversation and in Temporal history. So a saga that really ended declined because the tenant said so replays as an outreach exhaustion. Closing this needs a second input source (the conversation thread, or the exported Temporal history), which is unbuilt.

The 24 distinct event types in the corpus all map or are deliberately markers; nothing was left unmapped (unmappedEventTypes: []).


8. Mutation control

--mutate plants a wrong recorded outcome on the first MATCH row and requires the scorecard to flip it:

[control] planted recorded=executed on saga_d1490689-88c9-40b5-b8b2-ab87c0162d1a
          (was declined, verdict MATCH) → DIVERGED
[control] PASS — planted wrong outcome scored DIVERGED

Exit code is 1 if it does not. With --mutate the totals move 30/10 → 29/11, which is the control working.


9. Runtime

  • First run (including the prod capture): capture ~20 s, then the replay.
  • Cached runs: 40–68 s wall clock end to end for all 66 sagas, of which ~25 s is the one-time webpack workflow bundle (1.98 MB). Per saga the replay is tens of milliseconds for months of virtual time.
  • Cost: zero. No AWS writes, no Temporal Cloud, no LLM, no metered key.

10. What is left unbuilt

  1. The tenant-response half. No tenantIntent / deliveryStatus / pmAction input exists in this corpus, so the countersign wait, the post-signature cadence and every PM-driven terminal are unexercised. Needs the conversation thread or the exported Temporal history joined per saga.
  2. Cold-patch replay. Every run here reads patched() HOT. Proving the pre-patch cohort behaves needs Worker.runReplayHistory over exported prod histories (the existing renewal-workflow-replay-corpus.test.ts gate), widened past its --limit=10 --closed-within-days=30 export bounds.
  3. History sizes — not measured.
  4. Multi-run sagas. 2 sagas recorded more than one recordWorkflowOutcome; the lab drives one run, compares against the first and annotates the rest. Replaying a re-started renewal as a chain is unbuilt.
  5. AppFolio read fixtures. Eligibility and policy inputs are not replayed — loadSagaInitialState returns a synthetic OPENED state plus the real lease end. Real charge_detail / renewal_summary rows would make the policy-driven branches meaningful.
  6. No Harness Atlas row and no CI job — a local lab by instruction.

11. Typecheck and commit

npx tsc --noEmit -p testing-harness/tsconfig.json exits 2, but not because of this lab. That tsconfig sets rootDir: testing-harness and has no @/* path mapping, so it fails on anything reaching into src/ — the pre-existing testing-harness/scenarios/pms-conformance.ts and hundreds of src/lib/** TS2307 Cannot find module '@/...' lines are in the same output on a clean tree. The only error attributable to the new files is one TS6059 on timeskip/mocks.ts importing src/lib/platform/classify-renewal-error.ts, i.e. the same rootDir limitation. No type error in the lab's own code.

Committed on lab/renewal-workflow-replay as 6e72e7f937. Not pushed, no PR.

PropFlow Docs