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
The PMS stays the system of record; Clara becomes the system of work. In the Western Slope deck these are Levels 1 to 3.
| Camellia, late April to Oct 23 | Count |
|---|---|
| Leases that came due | 57 |
| Offer sent by Clara (text letter, call or text) | 32 |
| Of those 32: never replied, yet still decided | 19 |
| All 57: signed / notice / month-to-month / transfer / left | 25 / 19 / 6 / 5 / 2 |
| Negotiated anything | 4 |
Lesson: the offer itself is the conversation. Having the paperwork ready on time matters more than more messages.
Green = proven on a real AppFolio lease at the Willows. Tenant signature proven Oct 23, 10:09 AM Denver. Then cancelled and reset.
| Customer | Outreachleasing.renewals | Renewal paperworkrenewal.paperworkSender | New leasesnewLease.paperworkSender |
|---|---|---|---|
| Camellia | Live since April | clara: the older letter path (robot fills their PDF form). The recipe engine is not used there | team (off) |
| Western Slope | Off | unset = team. Deck ready; supervised week pending Fede | team (off) |
| Situs Group | Off | unset = team. Recipe drafted by the learner | team (off) |
Why Western Slope first (their AppFolio, Jul 24 to Oct 23):
| Today, by hand | Number |
|---|---|
| Leases due / got an offer | 26 / 15 |
| Offer sent before lease end, median | 47 days |
| Offers out by the 90-day mark | 0 |
| Offers rebuilt at least once | 7 of 15 |
| Due in the next 90 days with no live offer | 14 of 25 |
When an offer does go out, 14 of 15 sign. The gap is getting the packet out on time.
team → prepare → clara.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).
Last refreshed Oct 23, 2026.
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.
On this page
Existing workflow, existing queue, their settings, off in production until Fede turns it on.
| # | Step | Type | Proven when | Status |
|---|---|---|---|---|
| 1 | Company-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 default | code | Set once at the company, overridden at one home, nearest value wins; Western Slope policy resolves for all 69 homes | merged: 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 |
| 2 | Renewal 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 switch | code | A renewal text waits in the Slack thread, "ship it" sends it, "hold" stops it | mostly 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 |
| 3 | Robot 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 title | code (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 |
| 3b | Robot 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 flow | code (robot) | One offer created at a test property through the native flow and the resulting document matches what staff send | blocked on where to capture — Fede's call, see gates |
| 4 | Camellia 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 emails | code | Rendered copy for a Western Slope house names the address only; timezone read from the property | 4 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 |
| 5 | Sandbox 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 breaks | proof | Every open lease produces a text with the right home, rent and term, or a named reason it did not | rerun 09-28 on fixed code: 13 leases would start today, 25 by +90 — see dry run |
| 6 | Western 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 one | set values | Values visible on the company page | after 1 |
| 7 | Their 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 call | Western Slope | The renewal flow pre-fills the document at one of their properties | ask |
| 8 | Turn on: renewals module, autonomous renewals, write lock live, then invoice | Fede | First offer text delivered and logged | gated |
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.
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:
Rerun: the dry-run worktree's tmp-artifacts/wsr/rerun.sh; per-lease table saved with the session.
Read from their AppFolio, read-only, headless. Full text of every template saved with the session.
| Document | Offers, 12 months | Signed |
|---|---|---|
| AF Renewal Function: RENEW (SIMPLE) — one page: new dates, new rent, all else unchanged | 36 | 20 |
| Older AMENDMENT – RENEW (SIMPLE) | 7 | 1 |
| Add / remove tenant amendments | 6 | 2 |
| Northridge RENEWALS (full new lease) | 14 | 3 (7 unsigned since late July) |
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.
Older research and history, kept for the record. Where it disagrees with sections 1 to 6, sections 1 to 6 win.
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):
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.
Real calls and texts into the Willows test line, test tenant only, no real residents touched.
| Scenario | Voice | Text |
|---|---|---|
| Basic questions | partial | pass |
| Caller/texter not on file | fail — 99-call loop, dropped | fail — no reply |
| Names a different home | partial | pass |
| Negotiation | fail — nothing written | fail — canned line, silence |
| Manager decides yes/no | blocked, untestable here | blocked, untestable here |
| Move-out notice | fail — nothing written | not run |
| Spanish | fail — answered in English | pass |
| Asks for a human | fail — never disclosed AI | not run |
Top 5 gaps:
Western Slope live on renewals by Friday 2026-10-02, and invoiced for them. Done means all of this is true:
| # | Step | Owner | Proven when | Target day | Status |
|---|---|---|---|---|---|
| a | Phase 0 bug fixes | Builders | Each fix ships with the case that reproduced it, live at every property | Tue night 09-23 | merged / in review |
| b | Every renewal text names the home — company name + street + unit; property name is the address at Western Slope | Builders | A harness run shows the home on every outgoing message, none generic | Wed 09-24 | building |
| c | List 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 silently | Builders | The board lists every property at once and its rows match AppFolio | Wed 09-24 – Thu 09-25 | building |
| d | Company-level policy + eligibility thresholds + voiceCallEnabled, with property override | Builders | Set once at the company, overridden at one home, and the nearest value wins | Thu 09-25 | queued |
| d2 | Wire the AppFolio charge feed into eligibility for Western Slope (feed already answers) | Builders | Balance and late-payment counts come from real charge_detail rows, not the clean-balance default | Thu 09-25 | queued |
| e | Text first, no opening call, confirmed through the existing setting | Builders | A dry run sends the text and places no call | Thu 09-25 | queued |
| f | Clara texts Kat: tracked question, one chase, answer relayed | Builders | A harness test walks question → chase → answer back into the renewal | Fri 09-26 | queued |
| g | Resident replies held for human review | Builders | A reply arrives, Clara's draft waits in Slack, nothing sends until Kat or Bev approves | Fri 09-26 | queued |
| h | Willows dry runs with us as the residents | Builders | Offer, 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 skipped | Mon 09-29 | queued |
| i | FEDE: flip the Western Slope write lock to live | Fede | The robot writes one note to a Western Slope card | Mon 09-29 | gated |
| j | Kat walks the first list on the Wednesday call | Western Slope | Kat has inspected the list and says it is right | Wed 10-01 | gated |
| k | FEDE: turn it on for the 43 leases ending in the next 90 days (the 43 tracked occupancies in AppFolio's renewal index) | Fede | The first offer text is delivered and logged | Wed 10-01 | gated |
| l | Invoice Western Slope for renewals | Fede | The invoice is sent | Fri 10-02 | gated |
Lower priority, after go-live:
Questions we bring to Western Slope on the first Wednesday call:
Question for Situs: which months do they want lease ends to land in, and what incentive are they willing to give?
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.
Text works, calls do not, and the team learns about problems after the resident does.
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:
Voice stays for inbound (residents who call the line) and as an opt-in later step ("want a call? reply CALL").
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.
Bug fixes ship live, one PR each, with the reproduced case. Opened tonight:
The smallest thing that reuses everything and can be charged for:
Each item starts only when Kat, Bev or Jason names the pain:
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.
This audit reads the deterministic capture taken September 7–8, before Western Slope's September 9 upgrade to AppFolio Plus.
| Area | Western Slope | Camellia |
|---|---|---|
| How renewals are done | Mostly 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 module | Renewal module driven by PropFlow automation, autonomous since 2026-07-23 |
| Lease templates | 5 usable, no single default, plus a live "Sample Lease Template" | One renewal template, separate from the new-lease template |
| Month-to-month notice | 2 of 5 templates say 21 days; 3 say 30. Client-confirmed policy is 30 | No conflict found |
| Month-to-month fee | $150/month verbatim in 4 of 5 templates; the MTM-only template doesn't restate it | Not documented |
| Standard term | 12 months; Ember Estates' 14–18 month promo has no template of its own | 12 months |
| Renewal reports | Populated at the roll-up level; the workflow-level Renewal Index is thin — 27 of 43 with no offer drafted | Not re-pulled for this audit |
| AppFolio reminder emails | Likely not firing for most leases, since no offer object exists to remind about | Not re-verified |
| Property groups (Plus bulk renewals) | Already in use; the bulk-renewal feature itself is unverified since the capture predates Plus | n/a, single property |
| Owner approval per property | Not found; unverified | n/a |
What we change on our side
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.
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.
Two rigs, both reusing existing pieces, no CI, no review:
Results land in Lab results when the runs finish.
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.
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.
| Number | Fable said | Opus said | What is true | Definition used |
|---|---|---|---|---|
| Camellia case count | 52 | 91 | Both exact on their own terms: 91 in-window sagas cover 55 units / 56 people | A 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 contact | 2 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 failures | 41 of 52 (79%) | 24 of 91 (26%) prepare_failed | Not contradictory — different questions, all correct on their own terms | 79% of cycles got an offer (funnel). 20% of units hit a prepare failure at least once. 7% are still stuck today — the honest headline |
| Calls | 26 | 29 | 29 is the true count of calls placed | ElevenLabs is the system of record for "does the phone work"; DynamoDB is a partial mirror, good for "what happened to this cohort" (~26) |
| Renewed | 9 | 11 | 9, verified against AppFolio's own countersign date | The 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 volume | 43 in the next 90 days | 117 over 12 months, 18 in 2026, peak 38 in May–Jun 2027 | Same rows, same index — the difference is per-person vs. per-household | A renewal decision is made once per household. 117/18/38 is the right sizing; 43 over-counts by counting every named adult |
renewal_accepted, renewal_declined, change_renewal_term) have never fired in production, across all 40 stored calls.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.
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.
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.
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.
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.
Corpus: 66 sagas with a lease end between 2025-09 and 2026-12.
| 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 |
| saga state | count |
|---|---|
| MIGRATED_TO_WORKFLOW | 19 |
| NTV_RECEIVED | 14 |
| CANCELLED | 14 |
| PREPARE_FAILED | 7 |
| TRANSFERRED | 6 |
| EXECUTED | 4 |
| OFFER_PREPARED | 2 |
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.
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….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.
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.
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.
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.
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
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.
Source: scratchpad/report-code-arch.md
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).
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.
| 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.
| 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. |
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).
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.
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).
Property.pmsSource is "the sole gate for any push-to-PMS code path (triggerPmsSync, triggerRenewalSync)" — admin/dev/pms-inspector/entity-schemas.ts:2826.
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.
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.
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.
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.
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/dev/renewals-cohort/, admin/dev/access-inspector/_components/ArmsTab.tsx.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.
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).
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.
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.
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 72hby 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 bydefault; 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.
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).
1773625953462 appears in ~120 files — essentially all eval fixtures, replay corpora, canary/Willows harnessscripts and test data. CERTAIN, not a production behaviour risk.
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.)
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, nota renewal path. CERTAIN.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
westernslopepm. CERTAIN** (grep).
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.
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.
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).
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.
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 theirproduction values. To a reader of the repo alone the whole engine is dark. CERTAIN.
--holdover-in-workflow, --walker-spawn and pairs) — set-renewal-arms.ts:31-39. CERTAIN.
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.
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.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.
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.
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). (#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.
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.
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.
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:
65-day suppression marker (retry-failed-prepare.ts:818), a 30-minute retry cron that skips on the marker.
activities/renewal.ts:2432. **Confirmedstill present, CERTAIN.**
counter-offer and a paper-lease request each sat for weeks.
renewal-outreach-hold.ts's own header.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.
saveTenants discards the did-it-change result (src/lib/data/dynamo/property.ts:681-700).
measureOnly: true for renewal letters — F3, send-renewal-letter-mms.ts:200-221, 265-289; policy decision pending since 2026-09-03. Confirmed, CERTAIN.
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.
#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.
rent-roll poller emits saga EVENTS rather than competing writes (pms/writers/renewal.ts header), every branch idempotent on pmsRef.
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.
src/__tests__/workflow-permutations/renewal/{renewal-adapter.ts,renewal-scenarios.ts,baselines/renewal.golden.json}.
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.
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.
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.
resolve-account.ts) — a mis-scoped renewal offercannot land in the wrong customer's database by default. Exactly the property a second AppFolio customer needs.
(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.
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.
end-to-end proof safe to run (2026-09-04 page).
renewalWorkflow — workflows/renewal-workflow.ts:1035; registered type string 'renewalWorkflow' (:464).process.env.RENEWAL_TASK_QUEUE_OVERRIDE || 'propflow-renewal' (client.ts:135, workers/renewal-worker.ts:99, services/renewal-worker/index.ts:202-204).
workflowId = input.renewalId (the minted saga id) — client.ts:206-214.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 (no env flag).
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.
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' :468tenantIntentSignal '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.
retryLetterUpdate 'retryLetter' :492 · externalResolutionUpdate 'externalResolution' :549 · pmDecisionUpdate 'pmDecision' :586 (PM approval).
getSnapshotQuery 'getSnapshot' :654.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.
pms() === 'countersigned' via countersignTerminal() (~:1798-1804), fed by pmsChangedSignal from the rent-roll poller and the AppFolio countersign email parse.
| 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' |
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.
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.
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.
Math.random(), no uuid, no direct fetch/IO in any of the four core workflow files. CERTAIN.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.
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).
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.
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.
maxConcurrentWorkflowTaskExecutions: 10, maxConcurrentActivityTaskExecutions: 10 (renewal-worker.ts:112-114), labelled "Phase 0 defaults; production tunes these."
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.
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).
#alerts (C0BGTNSP04C) from CI, not from the worker.
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.
CERTAIN
continueAsNew against a 400-day run timeout, with two no-timeout waits and multi-week timers (§6.6).renewal-workflow.ts duplicates the non-retryable error-type list inline instead of importing the shared one.which is why the 50s hard-exit watchdog exists.
retireBy before it reds CI.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.
LIKELY
history-size incident found in the files read.
westernslopepm. Writesare 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.)
(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.)
board across ~70 buildings; the loader can already go org-wide, the page refuses to. (M, CERTAIN.)
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.)
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).
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.
Source: scratchpad/report-history.md
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:
renewal, renewals, countersign, holdover, month-to-month, mtm, NTV). 810 commits. This is the honest measure of renewal work.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.
Split leasing tab into New Leases and Renewals subtabs — the first renewals surface.feat: Renewals tab Phase 1 — KPIs, columns, filters, sort, conversation modal + first smoke/e2e suite. This is the earliest commit whose subject names renewals.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.outreach_sent, DynamoDB GSI eventual-consistency in renewal-trigger, Sentry capture added.Merge branch 'renewals-p1'), demo-driven, no PR numbers.#91 Approve → AppFolio sync, new renewal statuses.#128 autonomous outreach scaffolding + AppFolio test-property pipeline.#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.#278 auto-decline on NTV filed. #271 daily cron migrated launchd → Lambda + EventBridge.#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.#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.#384 minimum-increase policy (% or $/mo). #381 real Day-2 SMS nudge scheduler. #376 real AppFolio signature state on the detail page.#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.#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.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.#576 saga-aware reset.#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.#764 Sean's 5/7 UX review applied.#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).#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.#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.#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.#1376 consolidates send switches into one fail-closed gate, ships disarmed. #1378 term permutations (6/12/MTM) in the E2E matrix.#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.#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).#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).#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.#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.#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.#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.#2924 TRANSFERRED outcome (ADR-0076) — stop labelling unit transfers as Declined. #2851 simplified v2 list+detail behind a per-browser toggle.#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.#3220 Renewal Failure Gauntlet prod-repro harness Phase 1; #3222 alert-only Temporal workflow-health sweep cron.#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.#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.#3829) reconcile renewals against external PMS actions.#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).#4110 renewal voice honors caller opt-out. #4167 renewal offer emails sent from the property mailbox via Graph. #4144 stops junk stuck-saga pages.#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).#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.#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.#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.#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).#4541). The 09-04 audit notes a workflow built to test real AppFolio automatically was never run once and was deleted 2026-07-25.#4843/#4844 reverted by #4866 — quiet hours are the property's local time).#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.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.#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.#5014 a thrown PM escalation email is a failure, not a success. #5192/#5205 renewal escalations lose their follow-up promises.#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."#5438 re-send capability + preflight that refuses before the cancel. #5476 internal engineering review lane for renewal corrections.#5563 extends two renewal patch retireBy dates because "grace elapsed at midnight, required checks red repo-wide" — the first of four such episodes.#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.#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".#5800 retire six overdue renewal patched() guards (ADR-0108 D5).#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.#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.#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.#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.#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.#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.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.#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.#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.#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.#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.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/.
| 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).
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).
/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.(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.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 |
transitionSaga + state-machine.ts — PR #1250, 2026-05-20, ~12,834 lines removed.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.signer_missing_email gate (#3062).pmsWritesAllowedWhileTest opt-in (#9325, merged 2026-09-18).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."fede/ui-temporal-queries) — abandoned as architecturally backwards./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.
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.
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.
Source: /Users/federicochapa/.claude/propflow-docs/artifacts/renewals-architecture-decision-2026-09-03.html.
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:
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).
Source: scratchpad/report-temporal.md
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.hmqmais gone —nslookup propflow-renewal-prod.hmqma.tmprl.cloudreturns NXDOMAIN, and every CLI call against it fails withfailed 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).
Repo root: /Users/federicochapa/.claude/propflowai
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.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).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..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>).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.)propflow-renewalrenewalWorkflow (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 |
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).
.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.
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 |
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.
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.
executedLeaseFetchWorkflow — 19 of 22 Running, unbounded poll loopOldest: 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.
renewalWorkflowBoth are harness probes, both on 2026-09-20, both historyLength: 29:
worker-version-probe-1789895665729 — started 2026-09-20T09:14:26Z, closed 09:14:42Zsmith-reprobe-1789896489692 — started 2026-09-20T09:28:09Z, closed 09:28:25ZRoot 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.
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_*.
| 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".
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.
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.ADR-0045 cut — activating target schedules.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.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.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).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
UNVERSIONED for every poller — Worker Versioning is not in use (see §7).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.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.
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.
gh run list --workflow renewal-replay.yml --limit 55/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.
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.
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.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.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".pmsChanged, pmsFailed, deliveryStatus, tenantIntent, pmAction, pmStatusOverride. Updates (3): retryLetter, externalResolution, pmDecision. Query (1): getSnapshot.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.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.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'].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.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.
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.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.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.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.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).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.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.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.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.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.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.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.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.Source: scratchpad/report-camellia-history.md
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.
| 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.
(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.
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 | 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).
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 |
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 |
| 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.
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.
"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.
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.
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.
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 |
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:
preparedOffer.terms exactly.Two non-hallucination wrong-fact moments worth naming:
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.
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.
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).
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.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.RenewalOpened … Saga opened by pm), not the scanner.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.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.
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.
| 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 |
Source: scratchpad/report-harness.md
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.
The lab Fede is describing mostly already exists, in three pieces that have never been joined:
scripts/renewal-harness/replay/ — a hermetic, in-process 30-simulated-day walk of the real dailyrenewal scan over a seeded book. Real code, fake DynamoDB, fake Temporal, fake Twilio. Runs on every renewal PR. Synthetic data only.
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.
src/__tests__/renewal-workflow-replay-corpus.test.ts + scripts/canary/ — real exported prodTemporal 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.
| # | 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/.
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.
| 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).
| 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 |
| 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.
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).
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.
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.
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.
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.
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.
Source: scratchpad/report-customer-context.md
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.
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):
knowledge.lease-length, verdict Wrong only on the Ember detail).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.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."
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_c42d9150-b9ad-46f8-8f5f-b0fe56bacf38 "Western Slope Property Management", type centralized, purpose customer. Jay Taylor's org_admin invite accepted 2026-09-11.identity.type==='tenant' with no read of Property.capabilityStage.maintenance (='off' for WS). Same class of gap will apply to renewal asks from residents.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.
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:
<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).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."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.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.From the scraped AppFolio help KB (~/.claude/pms-knowledge-base/appfolio/help-articles/), article "AppFolio's Renewals Flow" and "FAQs: Lease Renewals":
/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"):
No 90-day expiration pull found. What does exist, from the go-live tracker (read 2026-09-23):
PMS_IMPORT#org_c42d9150…/ACCOUNT#appfolio#westernslopepm#17a5) wrote tenant: 142, lease: 142 in its "People and leases" stage.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.
"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.
Source: scratchpad/report-ws-lease-volume.md
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)
Camellia: 187 rows (126 active, 61 former)
Source: scratchpad/report-ws-appfolio-setup.md
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.
| Area | Western Slope | Camellia |
|---|---|---|
| How renewals are done | Mostly 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 module | Renewal module driven by PropFlow automation, autonomous since 2026-07-23 (policy: 2.0 months balance, 6 late payments, 25-day grace) |
| Lease templates | 5 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 default | One 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 days | No conflict found |
| MTM fee | $150/month verbatim in 4 of 5; the MTM-only template does not restate it in the captured section | Not documented |
| Standard term | 12 months; Ember Estates 14–18 month promo has no template (applied by hand, inference) | 12 months |
| Renewal reports | Populated 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-emails | Likely 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 property | Not found; unverified | n/a |
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.
Must-do before turn-on:
Nice-to-have:
westernslopepm as is.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
Source: scratchpad/lab/README-lab-A.md
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.
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).
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. |
contentHash 2f48e2b2e032aaab…)1773625953462)renewal_summary rows for AppFolio property 7; 63 of 120 sagas join to at least oneThe 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.
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).
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 failedWidening 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.
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.
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.| 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.
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.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.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.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.Source: scratchpad/lab/README-lab-B.md
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. 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.
| 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.
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.
| 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 |
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.
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").
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.
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.
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.None. 0 of 66 threw. No NondeterminismError, no unknown-signal rejection, no patch mismatch, no hang.
Two lab bugs, both fixed, both of a kind that silently fakes results — recorded because the same two will bite the next person:
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.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.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).src/__tests__/renewal-workflow-replay-corpus.test.ts does). See §10.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.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: []).
--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.
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.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.recordWorkflowOutcome; the lab drives one run, compares against the first and annotates the rest. Replaying a re-started renewal as a chain is unbuilt.loadSagaInitialState returns a synthetic OPENED state plus the real lease end. Real charge_detail / renewal_summary rows would make the policy-driven branches meaningful.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.