0098 — Executed-lease notice pipeline: decouple fetch from send, exactly-once outbox, PMS-agnostic acquisition

AMENDED 2026-08-13/14 (owner decisions, Fede) — the fetch never gives up, and waiting is SILENT. §3.1's 24h max-age wall, its 6h warn, and §7 Q1/Q2's AGED_OUT terminal are superseded: leases routinely take weeks or months to countersign — normal business, not an engineering condition — so the fetch workflow polls until the executed document exists (15m → hourly day 1 → 6-hourly week 1 → daily) with NO time-based alerts of any kind. The only page left is the wrong-document (content-gate reject) alarm. It never completes unfetched and never writes AGED_OUT; AGED_OUT survives as a manual-ops parking state only (inverse: revivePendingNotice). Driven by the 2026-08 incident: 11 real transfer executions countersigned late all aged out unsent — accounting silently received nothing (and the 11 age-out Sentry pages went unnoticed, proving time-based paging here is noise). See src/lib/temporal/workflows/executed-lease-fetch-workflow.ts (PRs #5741 + follow-up).


1. Context

When a lease is fully executed (signed + countersigned), the PMS notifies the office of nothing. PropFlow emails the property's accounting recipients a plain-English heads-up. This notice has broken in production three times in one month, each caught by the customer rather than by us.

Current architecture (verified in repo, 2026-07-16)

Three near-copy entry points all converge on the one shared send function notifyExecutionToAccounting (src/lib/domain/leasing/renewal-execution-notify.ts) — so the send template is already single-sourced. The breakage is not in the template; it is in the acquisition + orchestration wrapped around each caller:

Entry point File Runtime / budget Idempotency anchor
Renewal D3 src/lib/temporal/activities/renewal.tsrecordWorkflowOutcome (executed branch, line ~2442) Temporal activity, 60s startToClose alreadyDispatched(idempotencyKey) (at-most-once, keyed on the audit token)
New move-in / transfer (prospect-driven) src/lib/domain/leasing/lease-execution-notices.ts sweepLeaseExecutionNotices Vercel cron, 300s maxDuration, 150s soft budget ProspectInquiry.executionNoticeSentAt stamp
Transfer (occupancy-driven, no prospect) src/lib/domain/leasing/occupancy-transfer-notices.ts sweepOccupancyTransferNotices Vercel cron, 300s, 150s soft budget TenantOccupancy.transferNoticeSentAt stamp

All three, inline and under their own time budget, call fetchExecutedLeasePdf (renewal-orchestration/fetch-executed-lease-pdf.ts) → handleFetchLeasePdf handler → fetchLeasePdfL4 (Browserbase browser scrape of AppFolio, cold path ≈120s inline MFA) → content gate verifyExecutedLeaseContent (execution marker + tenant-name needle, fail-closed). Then they stamp, then send with the PDF attached.

The four root architectural flaws (confirmed with Fede)

  1. PDF fetch is coupled to the send. The fetch runs under the sender's clock (60s activity / 300s cron). A cold fetch (~120s) cannot fit the 60s renewal activity at all; the cron only fits it by burning almost its whole budget on one candidate. A single transient runner blip (2026-07-16) drops the attachment.
  2. Detection is single-channel. Only the Browserbase scrape of AppFolio confirms execution. No corroboration.
  3. Three near-copy orchestration paths drift. The send template is shared, but the stamp semantics, retry policy, gate ordering, and PDF-fetch wiring are re-implemented per path and have drifted (three different bugs in three paths).
  4. Link-only fallback degrades silently. notifyExecutionToAccounting sends deep-link-only when pdf is absent. #3954 turned that into a logCritical page, but the notice still goes out attachment-less.

2. Locked decisions (from Fede, 2026-07-16 — not relitigated here)


3. Decision

Split the pipeline into detection → durable ingest (fetch+cache) → durable outbox (send), with a PMS-agnostic acquisition interface and an S3 byte cache between them.

             ┌─────────────────────── detection sources (dedup on noticeKey) ─────────────────────┐
             │  renewal D3 (Temporal)   prospect sweep   occupancy sweep   [staff-JSON:API]  [inbox-signal SPIKE] │
             └───────────────────────────────────────────┬───────────────────────────────────────┘
                                                          │ emitExecutedLeaseEvent(kind, anchorRef, source, payload)
                                                          ▼
                                        ┌──────────── Notice Outbox (DDB) ────────────┐
                                        │  PENDING_NOTICE row, conditional-put create   │
                                        │  status: PENDING_FETCH → FETCHED → SENT       │
                                        └───────────────────┬──────────────────────────┘
                                                            │ start/signal (dedup by workflowId)
                                                            ▼
                              ┌───────── executedLeaseFetchWorkflow (Temporal, propflow-renewal) ─────────┐
                              │  patient retry: fetch via ExecutedLeaseSource → content gate → S3 cache    │
                              │  generous per-attempt budget; backoff to hourly; max-age escalation (page) │
                              └───────────────────────────────┬───────────────────────────────────────────┘
                                                              │ on cached PDF → row FETCHED → sendPendingNotice
                                                              ▼
                        ┌──────────── sendPendingNotice (ONE module) ────────────┐
                        │  read outbox row → GET bytes from S3 → REFUSE if absent  │
                        │  → notifyExecutionToAccounting (attach bytes) → row SENT │
                        └──────────────────────────────────────────────────────────┘
                     (reconcile sweep retries FETCHED-but-not-SENT + re-drives stalled fetch workflows)

3.1 Ingest pipeline — decouple fetch from send

A dedicated executedLeaseFetchWorkflow on the existing propflow-renewal Temporal task queue (reuse the existing renewal worker — services/renewal-worker/index.ts — which already has AppFolio L4 access and the S3-capable task role; no new worker, no new cluster).

3.2 Notice outbox — exactly-once, defer-not-degrade

A durable PENDING_NOTICE DynamoDB item (single-table), created the moment any entry point detects a lease-executed event.

Migration — do NOT strand in-flight state (2026-06-24 lesson). The outbox is purely additive:

3.3 S3 executed-lease cache

Reuse the propflow-photos bucket with a new executed-leases/ prefix — not a new bucket. Justification vs the evidence-bucket precedent (which did need a separate bucket): evidence needed Object-Lock, which can only be set at bucket creation and is incompatible with our requirement here (we want auto-delete at 48h, the opposite of immutable retention). Nothing about this cache needs a dedicated bucket; a prefix + a prefix-scoped lifecycle rule gives lifecycle isolation without new IAM surface. The renewal worker task role already holds s3:PutObject/s3:GetObject on propflow-photos/renewal-letters/* (reference: renewal_worker_task_role_s3); extend the resource allowlist to propflow-photos/executed-leases/*.

3.4 PMS-agnostic acquisition interface (L2)

Define ExecutedLeaseSource in the domain (not in integrations/):

// src/lib/domain/leasing/executed-lease-source/types.ts
export interface ExecutedLeaseFetchInput {
  pmsRef: string;            // opaque FK (AppFolio: occupancyId; Yardi: propertyId+leaseId)
  pmsPropertyId: string;
  externalLeaseDocId?: string;
  tenantIdentity: { firstName: string; lastName: string }; // content-gate needle
  idempotencyKey?: string;
  timeoutMs?: number;
}
export interface ExecutedLeaseDocument { pdf: Buffer; extractedText?: string; filename?: string; }

export interface ExecutedLeaseSource {
  readonly pmsType: PMSType;
  /** Return the content-VERIFIED executed lease, or undefined if not executed / unverifiable.
   *  The adapter runs the PMS-agnostic `verifyExecutedLeaseContent` gate before returning. */
  fetchExecutedLease(input: ExecutedLeaseFetchInput): Promise<ExecutedLeaseDocument | undefined>;
  /** Optional corroboration channel (phase 7): a cheaper/second signal that the lease executed. */
  corroborateExecution?(input: { pmsRef: string; pmsPropertyId: string }): Promise<{ executed: boolean; source: string }>;
}
export function getExecutedLeaseSource(pmsType: PMSType): ExecutedLeaseSource; // registry, dispatch by pmsType
// src/lib/integrations/yardi/executed-lease-source.ts  (SKELETON — not implemented)
export class YardiExecutedLeaseSource implements ExecutedLeaseSource {
  readonly pmsType = 'yardi' as const;
  async fetchExecutedLease(input: ExecutedLeaseFetchInput): Promise<ExecutedLeaseDocument | undefined> {
    // SIPP ItfCommonData GetLeaseDocuments-family call → select the executed/countersigned doc
    // → decode → verifyExecutedLeaseContent({ text, tenantIdentity }) → return or undefined.
    throw new Error('not implemented');
  }
}

3.5 Telemetry invariants (carry forward #3954 / #3953)

3.6 Multi-source detection (design only; built in Phase 7)

emitExecutedLeaseEvent(kind, anchorRef, source, payload) accepts a source enum: renewal-workflow | prospect-sweep | occupancy-sweep | staff-jsonapi | inbox-signal. Dedup on noticeKey; multiple sources for one execution append to detectionSources and produce one notice.


4. Consequences

Positive: fetch runs on its own patient clock (root flaw 1 gone); one orchestration module owns send (flaw 3 gone); link-only is structurally impossible (flaw 4 gone); detection is pluggable + multi-source-ready (flaw 2 addressed); PMS-agnostic core unblocks Yardi/RealPage; 48h KMS cache satisfies retention/PII.

Negative / cost: a new DDB item type + a new Temporal workflow + prod worker deploy (daylight-gated); a migration window with dual-write; more moving parts than three inline sweeps. Mitigated by additive migration (no behavioral gap) and by reusing the existing worker/queue/bucket/task-role (no new infra footprint beyond one S3 prefix + lifecycle rule + IAM statement).

Neutral: the two heavy cron sweeps shrink to a light reconcile sweep once all paths cut over.


5. Alternatives considered (rejected)


6. Phased implementation plan (per-PR scopes)

Ordering guarantees: one concern per PR; the two current sweeps + renewal D3 keep sending throughout; no window where notices stop. Each PR ships regression tests + a Willows E2E step.

Phase PR scope Concern Regression tests Willows E2E proves Size
0 Infra: executed-leases/ prefix, 48h lifecycle rule, SSE-KMS verify/enable, extend task-role + Vercel IAM to prefix, S3 access logging Storage substrate n/a (IaC) write→read→delete round-trip against prod bucket; get-bucket-lifecycle-configuration shows the 2-day rule scoped to the prefix Daylight (AWS console/IaC + KMS verify)
1 s3/executed-lease-cache.ts (put/get/buildKey/isConfigured), mirror evidence-bucket.ts. No wiring S3 cache module round-trip, key shape, not-configured guard store + read a real fetched Willows lease PDF through the module against the prod prefix Tonight
2 ExecutedLeaseSource interface + AppFolio adapter (wrap existing fetchExecutedLeasePdf + content gate) + registry + Yardi skeleton. Additive; existing callers untouched PMS-agnostic acquisition adapter returns undefined on content-gate reject; registry dispatch by pmsType fetch a Willows executed lease through the adapter, content gate passes Tonight
3 Notice-outbox DDB model + repository + interface (conditional-put create, guarded status transitions, TTL). No emitters. Dark Durable outbox record idempotent create (dup noticeKey appends source), status-guard on SENT transition, TTL set n/a (unit) Tonight
4 executedLeaseFetchWorkflow + fetchAndCacheExecutedLease activity + sendPendingNotice (refuse w/o bytes) + max-age escalation (24h claim-marker page, 6h warn) + reconcile sweep. Registered on propflow-renewal, worker deploy, not yet wired to emitters Ingest + send workflow fetch→cache→FETCHED→SENT happy path; sendPendingNotice never emails without pdfS3Key; max-age paths page once manually start the fetch workflow for a Willows synthetic execution → PDF cached, row FETCHED→SENT, email opened with attachment Daylight (Temporal + prod worker deploy)
5a Cut over renewal D3: recordWorkflowOutcome executed branch → emitExecutedLeaseEvent (outbox + start fetch workflow); dual-write legacy alreadyDispatched; remove inline fetch-at-send from this path only Renewal path cutover renewal executed → outbox row created, no inline fetch; dual-write asserted synthetic renewal execution on Willows → email w/ PDF via new path Daylight
5b Cut over prospect sweep (lease-execution-notices.ts): emit instead of stamp→fetch→send; dual-write executionNoticeSentAt New-move-in path cutover move-in signed → outbox row, dual-write stamp, old inline fetch gone synthetic new move-in on Willows → email w/ PDF Daylight
5c Cut over occupancy sweep (occupancy-transfer-notices.ts): emit; dual-write transferNoticeSentAt (at drain send time). Correction to the original plan: the NTV→TRANSFERRED saga relabel does NOT stay pre-emit — because emission is now pre-proof, a pre-emit relabel would falsely relabel an unproven transfer (occupancy live while the lease is still out for signing, the 2026-07-08 class). It moves to the proof-gated drain (applyLegacyPreSend in pending-notice-legacy-reconcile.ts, on a FETCHED row; THROWS to defer the send so the email never precedes the relabel) Transfer path cutover transfer → outbox row; drain relabels (proof-gated) + dual-writes stamp; a transient relabel failure defers the send synthetic transfer on Willows → email w/ PDF, source saga relabeled at drain time Daylight
6 Cross-entry-point invariant test; convert #3954 attachment-less logCritical to pre-send refuse-and-defer for the executed-lease category; retire inline-fetch coupling remnants Enforce L1 structurally the single invariant test (all emitters); forced attachment-less send is refused + deferred + paged attempt a forced attachment-less send → refused, deferred, paged (no email) Tonight (after 5)
7 Staff-JSON:API corroboration source (corroborateExecution); inbox-signal SPIKE (research doc only, no build) Multi-source detection two sources → one notice (dedup on noticeKey) two detection sources for one Willows execution → one email Daylight
8 Willows executed-lease canary harness (synthetic execution on appfolio-45 → assert cached PDF + opened email w/ attachment), wired into nightly regression Repeatable acceptance gate (L4) canary asserts full chain incl. opened email the canary itself, green nightly Daylight

Tonight-sized: 1, 2, 3, 6. Needs daylight (prod worker deploy / AWS infra / live prod cutovers with Willows E2E each): 0, 4, 5a/5b/5c, 7, 8.

No-gap guarantee: Phases 0–4 are all dark/additive — the three current paths send exactly as today. Each Phase-5 PR flips exactly one path and dual-writes its legacy stamp; the other two keep their current behavior until their own PR. At no point do notices stop.


7. Open product questions (NOT locked above — need Fede)

  1. Max-age escalation N. Proposed 6h warn / 24h page. Confirm the paging threshold and business-hours sensitivity (page at any hour, or defer to next business morning?).
  2. What happens at N-hours-unfetched. Proposed: never auto-send link-only; page + hold the outbox row (AGED_OUT) until a human fetches manually or dismisses. RESOLVED 2026-08-13/14 (Fede): keep polling forever, silently. There is no N — a still-uncountersigned lease at any age is normal business and emits no alert; the workflow only completes when the executed document is fetched. Link-only auto-send remains forbidden (unchanged).
  3. Corroboration-before-send. Is a content-gated PDF alone sufficient proof to send (my lean — the verified executed PDF is the proof), or should a second detection source (staff-JSON:API) be required before send to kill the 07-09 false-signed class outright?
  4. 48h TTL vs human-in-the-loop window. If a PDF is cached at hour 40 and a human acts at hour 47, the object may already be near expiry. Is 48h enough, or should the clock be "48h after cache" (per-object) rather than a blanket prefix rule? (Per-object expiry needs an object tag + tag-scoped rule.)
  5. Renewal payload parity. Renewal D3 sends newRent/termMonths (executed term); move-in/transfer omit them. Confirm the outbox payload should carry these for renewals only (not a blocker — defaulting to omit is safe).
  6. KMS on propflow-photos. Infra verification, not a product call, but must be resolved before Phase 0 ships: is the bucket SSE-KMS today, or does this need per-object KMS / a bucket-default change? (L3 requires KMS.)
  7. Legacy-stamp teardown timing. Coexist through migration, delete legacy reads in a follow-up ADR after >30d drain (ADR-0092 pattern). Confirm the drain window.