ADR-0066: Collapse the two-job renewal pipeline — wire eligibility into sends (finish Phase 3)

Decision: option C — the scanner absorbs eligibility scoring inline and gates the send on the verdict, in one testable function (classifyRenewalCandidate). Implemented behind the existing arm (nothing is armed for real renewal-offer sends by this work). Below records the current architecture, why it looks the way it does, its weaknesses, and the options considered.

Context

Autonomous renewals run as two independent daily Temporal schedules that do not talk to each other:

Why it's two jobs (not an accident — a deliberate, half-finished migration). Per docs/autonomous-renewals/architecture.md, the rollout was phased specifically to avoid a "silent renewal freeze": if Phase 1 had retired the legacy scanner and let the engine spawn sagas directly, every renewal would have parked awaiting a PM action while the HITL review UI didn't exist yet. So the engine shipped in shadow alongside the incumbent sender (Phase 1), the HITL UI shipped next (Phase 2), and Phase 3 — the cutover that collapses the two jobs into one and makes the verdict gate the send — was always the plan but has not landed. The two-job split is the transitional state, not the destination.

Current live state (verified 2026-06-22). Global arms are ON (autoStart, autonomousSending, holdoverConversion, set 2026-06-11). Camellia runs holdover-only (autonomousRenewalEnabled off), so no autonomous renewal offers have ever gone to a real tenant — the only cron-opened saga in prod is a holdover re-offer on the Willows test property. A read-only dry run of today's cohort: 34 tenants → 21 AUTO_RENEW, 13 HITL_REVIEW. If Camellia's flag were flipped on as-is, the next scan would start 17 offers — 10 of them HITL_REVIEW (the engine's verdict is computed and then discarded). That gap is the reason for this ADR.

Current architecture

flowchart TB
    subgraph today["Two independent daily schedules"]
        CW["renewal-cohort-walker-daily
08:00 MT · DECIDES · shadow"] AS["renewal-auto-start-daily
09:00 UTC · ACTS · armed"] end CW --> CWlogic["processCohortWalk()
AppFolio charge_detail →
policy gate (late / balance)"] CWlogic --> DEC[("RenewalEligibilityDecision
AUTO_RENEW / HITL_REVIEW")] DEC --> DASH["renewals dashboard /
admin dry-run (read-only)"] AS --> ASlogic["processRenewalAutoStart()
90-day window · skip in-flight ·
gate: autonomousRenewalEnabled"] ASlogic --> WF["renewalWorkflow (openedBy=cron)"] WF --> SEND["send activities
gate: RENEWAL_AUTONOMOUS_SENDING
+ autonomousRenewalEnabled"] SEND --> T(["tenant: SMS / email / voice offer"]) DEC -. "✗ verdict NEVER read by the sender" .-x ASlogic classDef decide fill:#1f3a5a,stroke:#57a7e0,color:#fff; classDef act fill:#5a4a1f,stroke:#e0b357,color:#fff; class CW,CWlogic,DEC decide; class AS,ASlogic,WF,SEND act;

Weaknesses

  1. The decision is discarded. The sender ignores AUTO_RENEW/HITL_REVIEW, so a flipped flag sends to flagged tenants anyway (today: 10 of 17). This is the live bug — and it can only exist because decide and act are two disconnected jobs.
  2. Two full cohort scans per day, an hour apart, both walking the cohort and both hitting AppFolio — redundant work.
  3. Staleness + window skew even if naively connected. The walker decides at 08:00 over a ~120-day window; the sender acts at 09:00 over a 90-day window. The decided population and the sent population aren't the same set, and a verdict can be an hour (or a missed-run day) stale at send time.
  4. No single place to assert the safety invariant. "Only AUTO_RENEW sends; HITL goes to review" spans two schedules plus a workflow — there is nowhere to unit-test it, which is how the gap shipped unnoticed.
  5. The sender's only per-tenant intelligence is the property flag — all-or-nothing per property; the rich per-tenant verdict sits unused one table over.

Decision (accepted — option C)

Finish Phase 3: collapse decide + act into one pass so the verdict gates the send, with the safety invariant testable in one place. Concretely:

flowchart TB
    ONE["one renewal dispatch job (daily)
90-day window · skip in-flight"] --> SCORE["score eligibility inline
(shared policy fn: late / balance)
+ write decision row (audit)"] SCORE --> BR{verdict} BR -->|AUTO_RENEW| GATE["send gate:
RENEWAL_AUTONOMOUS_SENDING
+ autonomousRenewalEnabled"] GATE --> T(["tenant: offer sent"]) BR -->|HITL_REVIEW| Q["PM review queue
(renewals dashboard)"] SCORE --> DEC[("RenewalEligibilityDecision
— same audit log, still dry-runnable")] classDef good fill:#1f5a2f,stroke:#57e07a,color:#fff; class ONE,SCORE,BR,T,Q good;

This is not arming anything. The arm path (global arm already on; per-property autonomousRenewalEnabled still off for every real property) is unchanged. The acceptance gate before any real flip: a test harness proving AUTO→sent / HITL→queued / not-ready→skipped on synthetic tenants, then a re-run of the Camellia dry run showing 0 HITL in the would-send set.

Alternatives considered

Consequences