The Prospect Activity Model

Design brief · 2026-07 · grounded in a 9-report code + prod audit (read-only aws dynamodb query against propflow-prod and an authed 229-row pull of GET /api/leasing/prospects), adversarially re-verified against working-copy HEAD 014bb34ba

6
mutually contradictory definitions of "prospect activity" in shipped code (plus a 7th that was designed to be the answer and has zero writers)
4m → 59d
drift between when an application was actually received and the appliedAt we display
0 of 5
live approved prospects carry an approvedAt — the field is written in memory and dropped at the DynamoDB boundary
17
production rows showing "—" (Camellia). The other 116 blanks are sandbox debris and must not size the work

TL;DR

This was never a display bug. PropFlow has no record that anything ever happened to a prospect — only a handful of mutable scalars on one row, three of which are untrustworthy (appliedAt is the poller's clock, approvedAt never persists, signedAt is date-only). Six surfaces each invented their own answer to "when was this lead last active" from whatever scalar sat nearest, and they disagree about the same prospect within an hour of it being created.

The fix is two layers: an append-only per-prospect event log that reuses the renewal lane's existing entity-activity machinery (zero new infrastructure), and two monotone pointer fields on the inquiry row so the list stays cheap. Every surface then reads one shared accessor. A 15-kind PMS-agnostic vocabulary and five strict time-basis rules keep the log honest — rule 4 alone ("never now() labelled as an event time") is the entire appliedAt fix.

Slice 1 has shipped: the shared accessor with a first-touch fallback to inquiryDate/createdAt — the only event-time-accurate PMS stamps we have — which clears the visible dashes with no log, no new storage, and no backfill. Everything after it is proposed and held for review.

Headline findings (all prod-verified)

1. One prospect, two surfaces, opposite answers — one hour old

prospect_appfolio_app_30_1784987585948 (Camellia, source Website, applied the same day). GET /api/leasing/prospects/<id> returns conversations: 0, tours: 0, signals: 0, conversationId: null, so the list column renders "—" (no activity, ever). The same payload carries createdAt 2026-07-25T13:49:00.000Z and inquiry.appliedAt 2026-07-25T13:53:05.948Z, which the detail page renders as three active rows — "Received in AppFolio", "Synced to PropFlow", "Application Submitted".

Worse: those last two print the same instant as two different events, because appliedAt is the sync clock. (The three-row render is not universal — app_29's receipt and sync minute are identical, so it renders two.)

2. appliedAt is the sync-run clock, confirmed three independent ways

The drift is unbounded, not poll-bounded — a backfilled or delayed ingest can be arbitrarily late:

Gap between real receipt time and the stored appliedAt

AppFolio Received (which is event-accurate, via createdAt/inquiryDate) vs. the field literally named appliedAt. Log-ish scale for legibility — exact value labelled on each bar.
Lori L. Warren — received 13:49:00Z
4 m 06 s
Jacob R. Warren — received 2026-07-24T19:06Z
6 h 02 m
Erol Gonzalez Batista — inquiry date 2026-04-27T16:00Z
~59 days
On this row-set, createdAt is a more truthful "when did they apply" than the field named appliedAt.

3. approvedAt is written in memory and dropped before DynamoDB

The PMS writer stamps merged.approvedAt, but the field appears nowhere in the DynamoDB writer allow-list — not in INQUIRY_C_FIELDS, not in InquiryFieldPatch, not in any setAlways/setIfAbsent in upsertInquiryProjection. grep -c approvedAt src/lib/data/dynamo/inquiries.ts = 0.

Prod: 1 of 229 rows carries it (a pre-collapse seed), and 0 of the 5 live approved-stage rows do. The detail page's "Application Approved" milestone therefore renders lit, with a blank date, on every approved prospect in production. "An approval is an activity" currently has no timestamp to read anywhere.

The lesson to carry into the design: the DDB writer allow-list — not the TypeScript type — is the gate. A type-level field with no allow-list entry is a silent no-op that survives months of unit tests, because reads spread raw attributes and pass through automatically. Any new field ships with a real-DDB round-trip test.

4. That dropped field also churns prod every 5 minutes

The sync's no-op guard compares existing.approvedAt === merged.approvedAt. Since the left side can never persist, the comparison can never match → write → value dropped → repeat every 300 s, forever. Measured live across two ticks by bounded DDB reads: exactly 8 Camellia rows re-write on every tick, and they are precisely the approved / rejected / lease-signed AppFolio cohort. Version counters: app_7 19,606 → 19,608 in five minutes; controls minted the same day sit at v=1.

This is the receipt for why a max(createdAt, updatedAt, appliedAt, …) patch was the wrong move — and the reason is sharper than "it's hacky". updatedAt is a write clock pinned to now for exactly that cohort, so a max would float the eight stalest rows permanently to the top of a recency sort while the honest 92% sorted correctly. It produces a worse ranking than the dashes. (Contrary to an earlier framing, updatedAt is not globally polluted: 158 of 229 rows are quiet >30 days. The churn is cohort-specific.)

A second bug rides along: one stamp site tests the raw mapped stage instead of the sticky computed nextStage, so a lease_signed resident whose AppFolio row still reads "Approved" is re-approved every tick. And adversarial verification found a third stamp site — in the co-applicant group-fold path added by PR #4553 — that none of the design drafts cited. A patch touching only the two documented sites leaves grouped applications churning forever.

5. Where the blanks actually live — the scoping correction

229 rows pulled; 133 render "—" (58.1%), 96 render a date. But 87% of the blanks are test data:

133 blank "Last Activity" cells, by property

Property reality from GET /api/properties. The Willows and yale-sandbox are organizationId: org_sandbox, isTest: true.
The Willows appfolio-45 sandbox
106 blanks (88 with a dangling or mismatched conversation FK)
yale-sandbox sandbox
10
Camellia 1773625953462 production
17 — all conversationId: null
Yale 25 Station production
0

On the only production property with blanks, all 17 have no conversation at all — zero dangling FKs, zero personId mismatches. So on real customer data, "PMS-sourced row with no Clara conversation" is 100% of the problem, not a third of it. Scoping the work off 133 would size the identity-remediation track roughly 5× too large.

Precise cohort: Camellia has 9 PMS-born rows and 7 render "—" (app_30, app_29, app_28, app_25, app_21, app_7, gc_6) — all 3 applications filed in the last 24 h among them. The other 10 Camellia blanks are Clara-lane rows with no conversation. A fix that covers only the 7 still leaves 10 dashes on the page.

6. The column is not merely blank — it is affirmatively wrong where it does populate

Of the 5 rows carrying both appliedAt and a displayed activity date, 4 show a date older than a real event on the same row:

ProspectappliedAtdisplayed "Last Activity"understated by
Tamar Blue2026-07-12T18:08Z2026-06-06T21:00Z36 days
Dilan S. Santamaria Castro2026-06-24T19:20Z2026-06-12T18:00Z12 days
Rodulf Parra Fuentes2026-06-29T21:33Z2026-06-26T23:15Z3 days
Carly Sprague2026-07-20T05:13Z2026-07-19T20:53Z8 hours

"—" reads as "no activity". A stale date reads as authoritative — the more dangerous failure.

The six contradictory derivations

Nothing is shared between them — not a constant, not a helper, not a type. A max-of-timestamps patch would have fixed one cell and made it seven definitions.

#SurfaceIts definition of "activity"AnchorFailure mode
1Prospects list — "Last Activity" columnmax(Conversation.lastMessageAt) joined by personId, bounded to the loaded property scopeload-prospects.ts:128Any prospect without a Clara conversation is "never active". Also scope-dependent — the same person reads differently on "All properties" vs one property.
2Same page — date filter + KPI tileslastActivityAt ?? inquiryDate, with APPLIED bypassing the window entirely_helpers/dateWindow.ts:60,63Contradicts the column immediately above it, on the same page. The carve-out exists only because the column is blank.
3Prospect detail — Activity LogA nine-slot milestone stepper: lead origin + conversation start + tours + lifecycle stampsProspectDetailClient.tsx:570-704time is a pre-formatted display string, never an ISO — structurally unusable as a recency source, so the two surfaces can never agree.
4Stale-lead digest (weekly call list, ADR-0060)lastMessageAt of the single most recent conversation; null → 0 daysbuild-call-list.ts:462-475A no-conversation lead maps to 0 days and is excluded by the 7-day minimum. Its own "inquired, not toured" cohort is structurally unreachable.
5Dashboard screening-pipeline staleness(updatedAt ?? createdAt) >= 30-day cutoffcompute.ts:913Row-write recency — so a sync tick counts as activity. app_21 counts as a fresh active lead five minutes after every tick while the list shows "—".
6Spine-orphans triagegetProspectLastActivityIso(p) => p.updatedAt ?? p.createdAtclassifiers.ts:498-500A function literally named prospect last activity that returns a different answer than the column named Last Activity. Zero shared code.
7(dead) data-layer recency keylastContactedAt || claraEngagedAt || inquiryDateleasing.ts:127-129lastContactedAt is documented as "sourced from the underlying conversation timeline" and is populated on 0 of 229 rows. The key silently degrades everywhere. This is the field someone already designed as the answer; it was never wired up.

Funnel and period metrics add a further axis, keying off createdAt and inquiry.appliedAt with no conversation dependency at all.

The design — two layers, one accessor, one vocabulary

Layer 1 — an append-only per-prospect event log (the truth)

EntityActivityEvent rows at PK=LOG#prospect#<inquiryId>, SK=EVT#<iso>#<eventId>. Zero new infrastructure — the seam is already cut and already accepts prospects:

Two structural fixes before the second entity type joins: appendActivityEvent/getActivityEvents currently live on the renewal-namespaced SagaStorage port, which is why nobody else has used them. Extract a standalone src/lib/data/entity-activity/ module and have DynamoSagaStorage delegate — not a lift onto IDataRepository, which would incur an unpriced JsonRepository implementation for the local-dev backend (local DATA_BACKEND=json gets an explicit no-op writer, acceptable because the writer is fire-and-forget by contract). And the reader currently queries with no Limit; the extracted one takes a bounded limit + ScanIndexForward: false.

Event ids are deterministicsha1(inquiryId|type|timestamp) — because the append is an unconditional putItem and the PMS poller is at-least-once. A replayed tick must overwrite, not duplicate. (The renewal lane's random id is safe only because Temporal gives it exactly-once.)

Layer 2 — two monotone pointers on the INQUIRY# row (the speed)

lastActivityAt?: string        // max(event time) over kinds where advancesRecency
lastActivityType?: ProspectActivityType

Plus the first writer for the already-designed, zero-writer lastContactedAt, advanced only by message and completed-call events. Three deliberate calls:

The pointer write is a sibling of updateInquiryFields, not upsertInquiryProjection (whose optimistic-lock version guard would manufacture ConflictErrors on a busy conversation). Structural precedent already in the file:

attribute_exists(PK) AND organizationId = :cOrg
AND (attribute_not_exists(lastActivityAt) OR lastActivityAt < :at)

Version-free, org-guarded, monotone, advance-only, and it never upserts a shell row. A losing concurrent write is a no-op, not an error.

One accessor, every surface

src/lib/domain/leasing/prospect-activity.ts
prospectLastActivity(p) -> { at: string|null; label: string; basis: 'event'|'observed'|'first_touch' }

Identical fallback chain everywhere: max(lastActivityAt, conversationLastMessageAt)inquiryDatecreatedAt. Never updatedAt, never appliedAt, never approvedAt, never signedAt.

This max() is not the patch that was rejected. The rejected one maxed over dirty scalars — updatedAt (a write clock pinned to now every 5 minutes for the approved cohort) and appliedAt (a poller clock with 4-minute-to-59-day drift). This one maxes over exactly two event-time-accurate sources and excludes every dirty one. The first_touch fallback is what clears all 17 production dashes with no log, no new storage and no backfill — because inquiryDate/createdAt are the only event-time-accurate PMS stamps in the system.

Conversations are deliberately not copied into the log

Conversations and tours are already durable, timestamped entities with their own rows. Copying every message into a prospect event log is double-bookkeeping that will drift within a quarter and multiplies write volume by message count. One storage is the wrong unification target; one composer is the right one. A pure, unit-tested buildProspectTimeline({ events, conversations, tours, inquiry }) returns entries carrying a real ISO instant, its precision and its basis — formatting happens in the component. The detail page renders it; the list reads the pointer.

The event vocabulary — 15 kinds, PMS-agnostic by construction

Lives in src/lib/data/statuses/prospect-activity.ts, following the existing statuses/ convention. No vendor word crosses into src/lib/data/types.tspmsType and external ids ride in payload, so Yardi / RealPage adapters fill what they have and leave the rest null.

#KindEmitted byAdvances recency
1inquiry_receivedguest-card lane + Clara mintyes
2guest_card_receivedguest-card writer, at gc.receivedAtyes
3message_receivedmessage laneyes
4message_sentmessage lane / outreach cadenceyes
5call_completedvoice laneyes
6tour_requestedtour writersyes
7tour_confirmedtour writersyes
8tour_cancelledtour writersyes
9tour_completedtour writersyes
10application_submittedrental-application writer, at app.receivedAtyes
11application_approvedrental-application writeryes
12application_deniedrental-application writeryes
13application_cancelledrental-application writeryes
14lease_signedprospect-conversion writer, day precisionyes
15pm_stage_changedPM PATCH route — on every stage change (today only 2 of 9 stages stamp anything)yes

advancesRecency is a static property of the kind in this table, not a per-event boolean — a per-event flag is forgeable by a careless writer; a table lookup is not. Pure re-observations (a sync tick that learns nothing new) emit no event, so the pointer never moves. That is what makes the design categorically immune to the churn that would have poisoned a max-of-scalars.

Two notable gaps the log closes without a schema migration: there is no deniedAt field anywhere on the inquiry, and closedAt/closedReason are writable with zero writers. The log carries application_denied — that is the whole point of having a log.

Time-basis rules

Every event carries its own instant (the sort key) plus payload.timeBasis: 'event' | 'observed' and payload.precision: 'instant' | 'day'.

#RuleWhy
1Source gives a real instant → use it, basis: 'event'.app.receivedAt / gc.receivedAt are verified event-accurate at the adapter.
2Source only says "it was true at the poll" → timestamp is the tick, basis: 'observed', written once and never re-derived.Honest about what we actually learned, and stable across re-observation.
3Date-only source → precision: 'day', normalize to T00:00:00Z, render as a date.signedAt is date-only in prod ("2026-07-15", "2026-06-30", "2026-07-01"). Never fabricate a clock time to make a sort look nicer.
4Never new Date().toISOString() labelled as an event time.That one rule is the whole fix for the appliedAt drift. The codebase already knows it — it is written as a comment sixty lines from the violation. Making both timestamps mandatory fields turns the comment into a type error.
5The adapter's ?? new Date().toISOString() fallback on an unparseable receipt must downgrade to 'observed', not mint a fake 'event'.Today an unparseable Received silently fabricates an event time indistinguishable from a real one.

Unmapped PMS truth we already fetch and throw away

AppFolio's rental-application and guest-card rows are declared in our own types with ScreenedOn, ApprovedAt, DeniedAt, CanceledAt, DecisionMadeAt, LastActivityDate and LastActivityType. A repo-wide grep across src, lambda and scripts returns only those eight declaration lines. Neither adapter reads them; the vendor-neutral domain types have no slot to carry them. AppFolio ships a per-guest-card last-activity feed with a typed reason, and PropFlow drops it at the adapter boundary.

Hard gate, and it is not optional. A second code path (the onboarding backfill) assumes a contradictory vocabulary — ApplicationDate / AppliedOn — and reads it. The repo therefore contains two mutually inconsistent guesses at what the same report returns, and neither has been checked against a live response. One read-only rental_applications.json + guest_cards.json fetch settles it. No adapter mapping ships before that lands: writing against declared-but-unverified columns is precisely how approvedAt reached its current state.

Slice plan

SliceScopeStatusDepends on
1The fix Fede sees. The shared prospectLastActivity() accessor with the first_touch fallback; the ProspectListRow type reconciliation; the list assignment; deletion of the date filter's if (stage === APPLIED) return true carve-out (it exists only because the column was blank); detail page renders "Approved — date not recorded" instead of a lit milestone with a blank date. Conversation join stays. No new storage, no backfill.Shipped
2Stop the churn — three stamp sites, not two. Add approvedAt to the writer allow-list and gate the stamp on a genuine transition; fix the stage-vs-nextStage test. ~30 LOC across all three sites including the group-fold path.Proposed — held
2bClose the AppFolio question. One read-only report fetch; reconcile the two contradictory column vocabularies. Zero code; findings land in the ADR.Proposed — held
3Substrate. Extract src/lib/data/entity-activity/ (standalone, bounded reader, delegating SagaStorage); the 15-kind vocabulary; the two pointer fields; recordProspectActivity + the conditional pointer writer. No producers yet.Proposed — held
4PMS producers. Emit through the existing application-stage-hook seam + guest-card + prospect-conversion. Deterministic ids. Verify by bounded DDB query that LOG#prospect#* fills.Proposed — held2, 2b, 3
5In-app producers. PATCH route emits on every stage change; tour lane; message lane writes both pointers.Proposed — held3
6Read reduction. Accessor prefers the persisted pointer; then delete the conversation-meta join from the list's Promise.all.Proposed — held5, 7
7Backfill. Dry-run default, --skip-test-properties, Camellia only, pre-run snapshot, --apply gated on explicit approval.Proposed — held4, 5
8Retire the other liars. Detail stepper sources its dates from the log; dashboard staleness, spine-orphans and the stale-lead digest come off updatedAt and onto the accessor.Proposed — held6
9(held separately) Repair the 11 drifted prod appliedAt values. Production data write — separate PR, explicit approval, snapshot.Proposed — held2b

The one improvement over every draft design

"Persist approvedAt and the churn stops" is true — but seed-once semantics mean the five currently-approved rows would be seeded with the deploy tick's now, so a PM reads "approved today" for an approval weeks old. That is the exact class of lie being removed. Gate on a genuine transition instead:

if (nextStage === APPROVED && existingProspect.stage !== APPROVED && !merged.approvedAt)
    merged.approvedAt = nowIso;

Then for the five existing rows both sides stay undefined, the no-op guard matches, the write loop stops, and nothing is fabricated. Genuine future transitions get a stamp truthful to within one 5-minute tick. Same guard at all three sites.

Regression tests that must fail on current code: (a) two consecutive sync runs over an approved application produce updated === 0 on the second — covering the per-application path and the group-fold path; (b) a lease_signed prospect whose PMS row still reads "Approved" is not re-stamped; (c) approvedAt round-trips through the real-DDB reader harness. A unit test would not have caught (c) — that is why the field was a silent no-op for months.

Backfill posture

Deterministic replay, idempotent by construction: stable event id + timestamped sort key means a re-run overwrites, and a monotone pointer cannot regress.

SourceEventBasis
createdAt / inquiryDateinquiry_received / guest_card_receivedevent — verified accurate, present on 228/229 rows
appliedAtapplication_submittedobserved — labelling it event would launder the existing lie
signedAtlease_signedobserved, precision: 'day'
Tour createdAt / history / cancelledAttour eventsevent
Conversation lastMessageAtpointers only, no per-message rowsevent
approvedAtnothing

Do not synthesize approvals. Zero of five live approved rows carry a timestamp and there is nothing honest to reconstruct from. The forward writer stamps a truthful first-observed time within one tick of deploy. Say that in the PR body so the gap reads as a decision, not a miss.

Risk is low and bounded because the backfill is not load-bearing — slice 1's first_touch fallback already covers all 17 production blanks, so if the backfill slips or is held, nothing regresses. Rehearse on the 116 sandbox rows first: free, and it exercises the path at ~7× production volume before touching real data.

Open questions for Fede

  1. "Last Activity" vs "Last Contact" — one column or two? DECIDED (Fede, 2026-07-26): keep the UI as it is today — one "Last Activity" column, no second column. The data model still tracks the two pointers separately underneath (a submit is an activity, not a contact, and lastContactedAt already has a consumer), but no new UI column ships; the stale-lead digest reads the contact pointer without changing the page.
  2. When the PMS tells us what changed but not when, what does the row say and where does it sort? Options: (a) "Approved — seen Jul 21", sorts fresh; (b) "Approved — date not recorded", does not move recency; (c) show the date with no qualifier. Lean (a) and yes-sort-fresh, because we did learn something that day. This is both customer-facing copy and a ranking rule.
  3. Repair the 11 drifted appliedAt values in prod? Fixing forward is free; fixing the existing 11 is a production data write.
  4. Sandbox backfill — skip or clean? 116 of 133 blanks are Willows / yale-sandbox debris. Skipping is cheaper and safer; cleaning matters only if those properties get demoed.

Flag, not a question: today the column is cross-property on "All properties" by accident. A per-inquiry pointer is more correct — a person's activity at another property is not this lead's activity — but it is a visible behavior change and belongs in the PR body.

Explicitly not being built

What this makes cheap later

PM response time on applications is currently unmeasurable: there are zero application_review timing rows in prod on any property (Camellia 0, Yale 25 Station 0, The Willows 15 — all renewal countersigns, on a test property). Once decisions are events with a real occurrence time, PM response time is a projection over the log — submitted-at to decided-at — rather than a parallel writer nobody wired. Don't build it in this arc; don't add a second timing writer either. The log subsumes it.

Sources: 9-report prospect-activity audit (surface map, prod data reality, lifecycle inventory, AppFolio sync trace, adversarial verification, three competing designs, synthesis verdict). All counts from a live authed 229-row prospect pull and bounded read-only propflow-prod queries; no writes were made. Numbers reflect the state of prod at audit time and line citations were re-anchored to working-copy HEAD 014bb34ba — earlier drafts' line numbers were stale after PR #4553.

PropFlow Docs