0082 — Owner & portfolio reporting: compose the two canonical shapes we already have
- Status: Accepted (v1 implemented in the same PR)
- Date: 2026-07-09
- Deciders: Fede (+ Gera, Sean)
- Trello: #1479 (leasing digest — extended by this ADR), #1480 (define "done" for CRM/RealPage migration — this ADR is that scope line), #1537 (Portfolio Intelligence → Asset Manager rename), #1325/#1423/#1424/#1425/#1333 (portfolio POC → production), #509/#367/#675 (report ingestion), #318 (parser coverage)
- Related ADRs: ADR-0073 (multi-source ingestion & reporting platform — the load-bearing prior art), ADR-0076 (transfers — first-party lifecycle event REJECTED; renewal-cohort trace only), ADR-0035 (person spine / occupancy-role rows), ADR-0047 (RenewalSaga read-model), ADR-0027 (canonical vs derived entities), ADR-0046 (holdover / MTM)
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:
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 cron0 16 * * 1wired in PR #3334; ships disarmed behind a two-factor send gate. Trello #1479.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+ derivedleased / 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.ownerReportWindowDays → 7. 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-unrented → NOTICE; 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:
- 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.
- The on-notice rented/unrented split is a real vendor concept (OneSite NTV-L vs NTV-NL) — our
noticeRented/noticeUnrentedgap maps onto it 1:1 when the normalizer stops collapsing the split. - "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.
- 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
- A new unified
WeeklyPropertyReportgod-type both producers emit. Rejected — duplicatesCanonicalPropertyReport(which already has the box score, nine adapters, reconciliation, roll-up) and recreates the drift this ADR exists to prevent. - Merging the funnel into the box-score contract. Rejected — different cardinality and lifecycle (event-flow over a window vs point-in-time snapshot). Compose, don't fuse.
- Sourcing renewals-by-month from
computeRenewalCounts(). Rejected on verification — no month axis, no MTM bucket. - A new
REPORT_CONFIGentity + repository interface. Rejected —PropertyLeasingSettingsalready owns per-property notification config; two optional fields beat a new row + two repo implementations. - Portfolio-first (build UDG, derive single-property). Rejected — the portfolio module is production-live for the INGESTED path (real per-org persistence, reconciliation), but deriving single-property owner reports from it would put the multi-source machinery in front of the simple own-data case; Camellia own-data is the fastest correct validation, and both paths meet at the shared box-score contract anyway.
- Temporal workflow for generation. Rejected — stateless weekly recompute; no durable timers/signals to justify it (and closes ADR-0073's open Temporal-vs-SQS question for report generation).