ADR-0066: Collapse the two-job renewal pipeline — wire eligibility into sends (finish Phase 3)
- Status: Accepted — option C (Fede, 2026-06-22)
- Date: 2026-06-22
- Deciders: Fede (owner), eng
- Relates: 0025 (Temporal renewals), 0047 (saga read-model), 0055 (renewal-view recorded state); supersedes the Phase 3 section of
docs/autonomous-renewals/architecture.md. - Implemented by: PR #2573 (the verdict gate + 3-layer verification harness).
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:
renewal-cohort-walker-daily(08:00 MT) — the decision engine. Reads AppFoliocharge_detail, applies the per-property policy gate (late payments / balance), and writesRenewalEligibilityDecisionrows labeling each tenant AUTO_RENEW or HITL_REVIEW. Runs in shadow: it does not spawn sagas and cannot send.renewal-auto-start-daily(09:00 UTC) — the sender. Scans the 90-day window, skips not-ready tenants (already-sent / in-flight / errored), gates on the per-propertyautonomousRenewalEnabledflag, and starts the renewal workflow that sends the offer. It never reads the decision rows.
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
- 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.
- Two full cohort scans per day, an hour apart, both walking the cohort and both hitting AppFolio — redundant work.
- 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.
- 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.
- 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:
- One daily job owns the flow the operator already expects: look at the next 90 days → skip not-ready → score eligibility → send only AUTO_RENEW → route HITL_REVIEW to the PM review queue.
- The eligibility scoring logic is reused as a shared function (not a second scheduled job); the job still writes the
RenewalEligibilityDecisionaudit row so the dashboard and the read-only dry run keep working. - Locked: the policy gate stays late-payments + balance only — no eviction/legal gate (deliberate; legal email signals are too flaky, and lateness empirically catches eviction cases — e.g. an active Writ of Possession surfaced as
late=7 → HITL).
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
- (A) Walker becomes the sole dispatcher (the
docs/autonomous-renewals/architecture.mdPhase-3 shape): the walker spawns sagas for AUTO and queues HITL; the auto-start scanner is disabled. Co-locates decide+act (no staleness) but is the largest rebuild — the window/dedup/holdover/workflow-start machinery currently in the scanner has to move into the walker. - (B) Sender reads the decision rows (lightest change): the scanner looks up each tenant's latest verdict before acting and skips/queues HITL. Smallest diff, but keeps both scans, the staleness window, and a new "verdict missing / walker didn't run today → fail open or closed?" failure mode.
- (C) One unified pass — recommended above. The scanner (which already owns window/dedup/holdover/workflow-start) absorbs eligibility scoring inline and acts on it; the separate walker schedule retires as a send-gating path (optionally kept purely as a read-only dashboard scorer). Same end-state as (A) with the least new code, and the invariant lands in one testable function.
Consequences
- Removes the entire class of "the two jobs disagree / don't talk" bugs (the HITL gap, and the decide/act staleness) and gives the safety invariant a single home + a single test.
- Prerequisite work this surfaces, to be done before any real arming: walker idempotency (it mints random ids per run → duplicate rows / double-fire once connected — Trello
frWGdBUd); confirm the pre-cutover stuck-saga drain (ADR-0047); the HITL review queue destination on the renewals page (partly exists via PM-escalation). - Cost: option (C) folds two code paths into one and needs the test harness + a re-validated dry run before the per-property flag is ever flipped on a real property.