0082 — Owner & portfolio reporting: compose the two canonical shapes we already have

Context

Founder decision D1 (2026-07-07 sync, docs/planning/yale-integration/sources/05): build our own leasing CRM and drop RealPage's ILM — "the only thing missing is reporting." Reporting parity is the named blocker to migrating Yale off RealPage lead management (Fede, 2026-07-06: "prospects won't go into RealPage so reporting will stop working… we could take that over" — ~$400/mo saved). This ADR draws the scope line #1480 asks for.

Two report subsystems already existed and were about to drift:

  1. src/lib/domain/leasing/reporting-digest/ (LeasingReport) — the leasing funnel: leads / tours / applications / leases-signed + conversions + lead-source mix. Shipped PR #3313 (2026-07-08); weekly cron 0 16 * * 1 wired in PR #3334; ships disarmed behind a two-factor send gate. Trello #1479.
  2. src/lib/domain/properties/portfolio-reports/ (CanonicalPropertyReport + WeeklyReportRow, ADR-0073) — the occupancy + financial box score: units / occupied / vacantRented / vacantUnrented / noticeRented / noticeUnrented / ntv / moveIns / moveOuts / delinquency0to30 / delinquency30Plus / cash / payables + derived leased / occupancy / netLeased / netOccupancy. Already ingests external managers' reports through format adapters (onesite-boxscore, appfolio-ar-aging, weekly-summary, generic/engine, …), reconciles cross-source disagreements (contestedFigures, heldForReview), and rolls up to the per-org portfolio dashboard. Powers the UDG Asset Manager Module (signed pilot SOW 2026-07-08, $15k/mo → $1/unit/mo).

The parity target — the real weekly owner report (CONAM's Yale 25 Station "Monday Report", Mon–Sun, to the JP-Co owner group) — is, field-for-field, the union of those two shapes plus one block neither has: occupancy % + pre-leased + on-notice + move-ins/outs (box score) · tours/applications/leases (funnel) · renewals broken out by lease-end month (renewed / notice / MTM / transfer / pending / % renewed) · a delinquency section · concessions.

Three use cases, two data origins (the crux):

Use case Data origin Level
Camellia (practice — has full own data + AppFolio + live renewals) own data (Clara funnel + platform stats + RenewalSaga + AppFolio aged-AR snapshots) single property
Yale 25 Station (CRM replacement) ingested — email-based (founder answer 2026-07-09); box-score adapters parse the manager's weekly reports; funnel from Clara once leads flow single property
UDG / Ulysses (Asset Manager Module) ingested — 9 properties, multiple PMSs, 3 managers, email-in portfolio

Founder answers folded in (2026-07-09): (1) Yale = email-based ingestion, not scrape/API; (2) PropFlow's report supplements CONAM's Monday Report, does not replace it; (3) email-only, no file attachments for v1 (UDG's existing xlsx master-report output in portfolio-reports is unaffected — this applies to the new single-property owner email); (4) delinquency from AppFolio confirmed — PMSClient.listBalances / persisted TenantBalanceSnapshot.bucketedAR + the appfolio-ar-aging ingestion adapter; (5) the Camellia practice send goes to Fede only.

Decision

D1 — Compose the two canonical shapes; do not invent a third, do not merge them. OwnerReport = LeasingReport (funnel) + WeeklyReportRow (box score, ADR-0073) + RenewalCohortByMonth (new) — a thin composition keyed by property + period (owner-report.ts). The composition holds references to the upstream types (pinned by the composition test asserting object identity), so no metric is ever defined twice. Rendering (owner-report-email.ts) picks which block supplies which line; the funnel and the box score keep their own contracts.

D2 — Add exactly one new derived block: RenewalCohortByMonth (renewal-cohort.ts). Groups getRenewals() read-model rows by the month their cycle lease ends (the read-model pins the cycle's leaseEnd even after rent-roll rolls tenant.leaseEnd forward). Buckets are mutually exclusive with precedence transferred → renewed → notice → MTM → pending, and always sum to expiring. Strict semantics reused from computeRenewalCounts (Sean 2026-05-13): renewed counts only countersigned-this-cycle; a verbal yes is pending; % renewed = renewed / (renewed + notice) with transfers and MTM excluded from both sides; null when nothing resolved. computeRenewalCounts itself was verified NOT to fit (current-cycle portfolio buckets — no month axis, no MTM bucket); its bucket definitions are reused, not its output. Transfers here are the renewal-cohort trace only (RenewalSaga.state === 'TRANSFERRED'): ADR-0076 rejected a first-party transfer entity, so a transfer entered directly in the PMS with no prior saga leaves no record — this is the grid's "transferring" line, NOT a portfolio-wide weekly transfer count.

D3 — The own-data box-score producer maps already-computed platform figures; it never re-derives. box-score.ts emits a WeeklyReportRow from: unit statuses under the single-source rent-roll discipline (isUnitOccupied — NOTICE counts as occupied; VACANT_RENTED = pre-leased; NOTICE = ntv), the dashboard's exact move-in/out predicates (occupancy leaseStart/leaseEnd in the window), and persisted TenantBalanceSnapshot.bucketedAR (AppFolio aged_receivables_detail sync; future bucket excluded; synthetic rows ignored; newest-per-tenant). cash/payables/noticeRented/noticeUnrented/concessions are null gaps — PropFlow's own model doesn't carry them (see the derivability table below). Derived metrics come from portfolio-reports' deriveMetrics() — the same formulas ingested rows get.

D4 — Cadence: weekly, Monday; window resolves per property with no implicit 30. The cron stays 0 16 * * 1. Window resolution: run input → PropertyLeasingSettings.ownerReportWindowDays7. The ?windowDays=7 query override was REMOVED from vercel.json (it would have permanently shadowed the per-property setting). Date strings are date-only (no TZ slicing); the Monday-16:00-UTC fire time is mid-morning in every US property timezone, so UTC "today" == property-local "today" at fire time.

D5 — Report config lives on PropertyLeasingSettings, not a new entity. ownerReportRecipients ({name?, email}[], same shape and posture as renewalExecutionCcEmails — notification-only, runtime config, never code) and ownerReportWindowDays. Fallback recipient: Property.propertyEmail. No new REPORT_CONFIG row/interface — the existing settings row already owns per-property notification config (scope discipline).

D6 — Reports are immutable once sent; snapshots power week-over-week. owner-report-snapshot-store.ts (mirrors the portfolio-reports persistence pattern: DATA_BACKEND switch, lazy dynamo-helpers import, memStore json fallback; own PK namespace OWNERREPORT#{propertyId} / TO#{periodTo}). Written only at real send (first-write-wins — a re-run never rewrites what an owner received; dry runs, previews, and manual CLI sends never snapshot). Next week's build reads the prior snapshot for WoW deltas (occupancy pts, past-due $, funnel counts); no prior snapshot → no trend rendered, never fabricated.

D7 — v1 = branded HTML email, no file attachments (founder answer 3). Sections: occupancy box score (+ WoW) → leasing activity → lead sources → renewals-by-month grid (+ standing-MTM and undated-renewals footnotes) → past-due aging (+ WoW). Plain English throughout; "MTM" is allowed (the industry's own term, used in the reports we mirror) and expanded once in the footnote. Byte-for-byte xlsx/PDF reproduction is out of v1.

D8 — Quiet ≠ vacuous. An owner expects the weekly report even in a zero-activity week — the funnel section says so honestly while occupancy/renewals still report. isEmpty (never send) is only the vacuous case: no units AND no funnel activity AND no leases in the renewals window. This deliberately differs from the funnel-only digest's old skip-on-quiet rule.

D9 — Generation stays stateless; no Temporal. Pure recompute over current state on a Vercel cron (the snapshot write is an append-only audit log, not workflow state). Auxiliary loads (renewals, balances, settings) are fail-soft: a failed read degrades its block to an honest gap and never fails the property; per-property try/catch isolates the scan.

D10 — The three-gate fail-closed send posture is unchanged. Schedule-wired ≠ sends: the global env arm + the per-property opt-in must both open, and ships disarmed. The manual path (scripts/preview-leasing-report.ts --send-to <addr>) is an operator-invoked one-off to exactly one explicit address — no default, no recipient expansion, no snapshot — and is how the Camellia practice send to Fede runs without arming production.

Entity classification (per ADR-0027)

Entity Class Naming Derived-from / rebuilt-by / drift-tolerance
RenewalCohortByMonth derived *ByMonth from: Renewal read-model rows grouped by cycle lease-end month · Rebuilt by: weekly cron / preview CLI · Drift: ≤1 week
OwnerReport derived *Report composition of LeasingReport + WeeklyReportRow + RenewalCohortByMonth (references, not copies) · Rebuilt by: weekly cron · Drift: ≤1 week
OwnerReportSnapshotRecord derived *Snapshot* from: the sent OwnerReport (projection: periodTo/occupancy/totalDelinquency/funnel) · Written at send, first-write-wins, immutable · Store: OWNERREPORT#{propertyId} / TO#{periodTo}

No new box-score or funnel entity — those are reused (CanonicalPropertyReport/WeeklyReportRow, LeasingReport). PropertyLeasingSettings gains two optional fields (existing canonical entity, no reclassification).

Own-data derivability (verified 2026-07-09)

Report figure Status Source
Occupancy / occupied / vacant Unit.status single-source (isUnitOccupied; NOTICE = occupied)
Pre-leased (vacant-rented) UNIT_STATUS.VACANT_RENTED (NMHC "Vacant-Leased"; the dashboard's pendingLeases)
On notice UNIT_STATUS.NOTICE (never event timestamps — see the Camellia 309/311 stale-event bug)
Move-ins / move-outs occupancy leaseStart/leaseEnd in window (the dashboard's exact predicates — report and dashboard can never disagree)
Delinquency aging persisted TenantBalanceSnapshot.bucketedAR (AppFolio sync)
Renewals by month ✅ (new) Renewal read-model + RenewalCohortByMonth
Pre-leased among on-notice ❌ gap rent-roll normalizer collapses notice-rented/notice-unrentedNOTICE; noticeRented/noticeUnrented stay null (slots exist on the contract)
Weekly transfers (movement row) ❌ gap ADR-0076 rejected the transfer entity; renewal-cohort trace only
Concessions ($) / cash / payables ❌ gap no ledger fields in PropFlow's own model — null, rendered "—" or omitted

Gaps render honestly and close later (ingestion supplies them for Yale/UDG; native capture is future work).

Industry findings (deep research, adversarially verified 2026-07-09)

A 103-agent verification pass over vendor documentation pinned four facts that shaped the contract:

  1. Occupancy % = simple physical occupancy (Total Occupied ÷ Total Units, no notice/pre-lease adjustment) per RealPage OneSite's own Box Score docs — exactly what this report computes (HIGH confidence, vendor primary source). RealPage reports several adjacent flavors (Net Leased %, Net Exposure) on the same sheet, so any figure labeled "occupancy" must say which it is; ours is the plain physical one.
  2. The on-notice rented/unrented split is a real vendor concept (OneSite NTV-L vs NTV-NL) — our noticeRented/noticeUnrented gap maps onto it 1:1 when the normalizer stops collapsing the split.
  3. "Exposure %" is definitionally unstable across (and even within) vendors — this report deliberately does not render an exposure figure; if one is ever added, its formula must be self-defined on the report.
  4. No cross-vendor standard exists for renewal reporting — lookahead windows vary 60–120+ days by operator, and every claimed standard renewal-rate formula / MTM / transfer treatment was refuted. Consequence: the report states its own definition in the footnote ("% renewed counts signed renewals out of decided leases (signed + notices); pending, MTM, and transfers aren't counted until they resolve") rather than assuming a shared convention. Sub-questions on RealPage ILM's exact weekly sections and institutional roll-up cadence returned zero surviving claims — the ground truth for those remains the real Yale Monday Report and the UDG SOW, which is what this contract was built from.

Durable vendor-agnostic findings are distilled into pm-domain-knowledge (owner-report conventions doc).

Scope — the #1480 "define done" line

In scope (this ADR, v1 shipped) Out of scope (separate track)
Composed owner report (box score + funnel + renewals-by-month) from own data RealPage/Yardi formal API integration ("checkmark", #1482)
Weekly HTML email, per-property recipients/window, snapshot-at-send, WoW Utility-data centralization (UDG SOW Phase 3, #1334)
Preview CLI incl. explicit manual --send-to Auto-applying ingested reports to tenant/unit records (#367/#675)
Fail-closed gates preserved; quiet-week send semantics Yale renewals-report parser (#509 schema-discovery pattern) — needed before Yale's ingested renewals grid
Feeding own-data rows into the portfolio reconciliation as a second source (the D3-vision cross-check)
xlsx/PDF attachments; replacing CONAM's Monday Report (we supplement)

Consequences

Easier: one metric definition per figure (funnel = LeasingReport, box score = ADR-0073 contract, renewals = one cohort module); Camellia → Yale → UDG is producer-swapping, not report-rewriting; the portfolio roll-up + reconciliation + honest-gap UI already exist for the ingested path.

Commitments: composition must keep referencing upstream types (drift-pinned); every figure traces to a real field or renders "—"; the quiet-week send semantics are now load-bearing for owner expectations; moveOuts mirrors the dashboard's leaseEnd-in-window predicate — refining both to Lease.leaseMoveOutDate (the PM-processed actual date) is a single shared follow-up in compute-light.ts, not a report-side fork.

Follow-ups: Yale renewals-report parser (#509); notice-rented/unrented split in the rent-roll normalizer (small, closes the pre-leased gap); decide on reopening ADR-0076's transfer signal vs accepting cohort-only; retire build-digest.ts's owner-facing metric overlap (three surfaces compute occupancy-ish numbers today — this ADR composes two; the KPI digest should follow); close the stale UDG Trello verification cards (#1423–#1425 — the live loader already serves real per-org persisted reports with isSample: false and honest-empty fallback; the cards predate PR #2804/#2811 and should be verified-then-archived, per Fede 2026-07-09); the ADR-0073-review security item (UDG "Login Info" credentials tab) must not be lost.

Naming

Product name = "Asset Manager Module" (founder rename, #1537; "Portfolio Intelligence" is the old name; "Intelligence Module" is a future upsell tier; "Asset Intelligence" in earlier notes refers to this same module). Code renames ride #1537, not this ADR.

Alternatives considered