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
appliedAt we displayapprovedAt — the field is written in memory and dropped at the DynamoDB boundaryThis 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.
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.)
appliedAt is the sync-run clock, confirmed three independent waysnow.toISOString(), sixty lines from a sibling call that deliberately passes app.receivedAt with the in-code comment "never now()".appliedAt values in prod: 10 of 11 land in a 158 ms window, at second :05.869–:06.027 of their minute. Humans do not submit applications at the same sub-second offset ten times. That is a cron tick.now.getTime(), and its decoded epoch is byte-identical to the stored appliedAt on every id-stamped row (app_30 → 2026-07-25T13:53:05.948Z, app_29 → 12:53:05.893Z, app_28 → 01:08:05.898Z, app_25, app_21).The drift is unbounded, not poll-bounded — a backfilled or delayed ingest can be arbitrarily late:
appliedAtReceived (which is event-accurate, via createdAt/inquiryDate) vs. the field literally named appliedAt. Log-ish scale for legibility — exact value labelled on each bar.On this row-set,createdAtis a more truthful "when did they apply" than the field namedappliedAt.
approvedAt is written in memory and dropped before DynamoDBThe 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 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.
229 rows pulled; 133 render "—" (58.1%), 96 render a date. But 87% of the blanks are test data:
GET /api/properties. The Willows and yale-sandbox are organizationId: org_sandbox, isTest: true.appfolio-45 sandbox1773625953462 productionOn 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.
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:
| Prospect | appliedAt | displayed "Last Activity" | understated by |
|---|---|---|---|
| Tamar Blue | 2026-07-12T18:08Z | 2026-06-06T21:00Z | 36 days |
| Dilan S. Santamaria Castro | 2026-06-24T19:20Z | 2026-06-12T18:00Z | 12 days |
| Rodulf Parra Fuentes | 2026-06-29T21:33Z | 2026-06-26T23:15Z | 3 days |
| Carly Sprague | 2026-07-20T05:13Z | 2026-07-19T20:53Z | 8 hours |
"—" reads as "no activity". A stale date reads as authoritative — the more dangerous failure.
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.
| # | Surface | Its definition of "activity" | Anchor | Failure mode |
|---|---|---|---|---|
| 1 | Prospects list — "Last Activity" column | max(Conversation.lastMessageAt) joined by personId, bounded to the loaded property scope | load-prospects.ts:128 | Any prospect without a Clara conversation is "never active". Also scope-dependent — the same person reads differently on "All properties" vs one property. |
| 2 | Same page — date filter + KPI tiles | lastActivityAt ?? inquiryDate, with APPLIED bypassing the window entirely | _helpers/dateWindow.ts:60,63 | Contradicts the column immediately above it, on the same page. The carve-out exists only because the column is blank. |
| 3 | Prospect detail — Activity Log | A nine-slot milestone stepper: lead origin + conversation start + tours + lifecycle stamps | ProspectDetailClient.tsx:570-704 | time is a pre-formatted display string, never an ISO — structurally unusable as a recency source, so the two surfaces can never agree. |
| 4 | Stale-lead digest (weekly call list, ADR-0060) | lastMessageAt of the single most recent conversation; null → 0 days | build-call-list.ts:462-475 | A no-conversation lead maps to 0 days and is excluded by the 7-day minimum. Its own "inquired, not toured" cohort is structurally unreachable. |
| 5 | Dashboard screening-pipeline staleness | (updatedAt ?? createdAt) >= 30-day cutoff | compute.ts:913 | Row-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 "—". |
| 6 | Spine-orphans triage | getProspectLastActivityIso(p) => p.updatedAt ?? p.createdAt | classifiers.ts:498-500 | A 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 key | lastContactedAt || claraEngagedAt || inquiryDate | leasing.ts:127-129 | lastContactedAt 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.
EntityActivityEvent rows at PK=LOG#prospect#<inquiryId>, SK=EVT#<iso>#<eventId>. Zero new infrastructure — the seam is already cut and already accepts prospects:
entityType already includes 'prospect' (types.ts:6123), and the type's own doc comment names prospects as the intended future member.entityLogPK already accepts 'prospect' (helpers.ts:406-409). Per-entity partitions: naturally sharded, bounded reads, no cross-org hot key.channel already includes 'pms'; payload is loose by contract.LOG#prospect#prospect_appfolio_app_30_… → Count: 0. No migration hazard.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 deterministic — sha1(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.)
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:
lastContactedAt; the list column reads lastActivityAt. The disease was six accidental definitions, not two deliberate ones.lastActivityType at the UI boundary.InquiryFieldPatch but NOT INQUIRY_C_FIELDS. C-fields are seeded through setIfAbsent, so a C-field pointer would freeze at its first value forever. This is the exact posture the file already documents for the cadence stamps.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.
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) → inquiryDate → createdAt. Never updatedAt, never appliedAt, never approvedAt, never signedAt.
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 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.
Lives in src/lib/data/statuses/prospect-activity.ts, following the existing statuses/ convention. No vendor word crosses into src/lib/data/types.ts — pmsType and external ids ride in payload, so Yardi / RealPage adapters fill what they have and leave the rest null.
| # | Kind | Emitted by | Advances recency |
|---|---|---|---|
| 1 | inquiry_received | guest-card lane + Clara mint | yes |
| 2 | guest_card_received | guest-card writer, at gc.receivedAt | yes |
| 3 | message_received | message lane | yes |
| 4 | message_sent | message lane / outreach cadence | yes |
| 5 | call_completed | voice lane | yes |
| 6 | tour_requested | tour writers | yes |
| 7 | tour_confirmed | tour writers | yes |
| 8 | tour_cancelled | tour writers | yes |
| 9 | tour_completed | tour writers | yes |
| 10 | application_submitted | rental-application writer, at app.receivedAt | yes |
| 11 | application_approved | rental-application writer | yes |
| 12 | application_denied | rental-application writer | yes |
| 13 | application_cancelled | rental-application writer | yes |
| 14 | lease_signed | prospect-conversion writer, day precision | yes |
| 15 | pm_stage_changed | PM 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.
Every event carries its own instant (the sort key) plus payload.timeBasis: 'event' | 'observed' and payload.precision: 'instant' | 'day'.
| # | Rule | Why |
|---|---|---|
| 1 | Source gives a real instant → use it, basis: 'event'. | app.receivedAt / gc.receivedAt are verified event-accurate at the adapter. |
| 2 | Source 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. |
| 3 | Date-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. |
| 4 | Never 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. |
| 5 | The 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. |
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.
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 | Scope | Status | Depends on |
|---|---|---|---|
| 1 | The 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 | — |
| 2 | Stop 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 | — |
| 2b | Close the AppFolio question. One read-only report fetch; reconcile the two contradictory column vocabularies. Zero code; findings land in the ADR. | Proposed — held | — |
| 3 | Substrate. 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 | — |
| 4 | PMS 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 — held | 2, 2b, 3 |
| 5 | In-app producers. PATCH route emits on every stage change; tour lane; message lane writes both pointers. | Proposed — held | 3 |
| 6 | Read reduction. Accessor prefers the persisted pointer; then delete the conversation-meta join from the list's Promise.all. | Proposed — held | 5, 7 |
| 7 | Backfill. Dry-run default, --skip-test-properties, Camellia only, pre-run snapshot, --apply gated on explicit approval. | Proposed — held | 4, 5 |
| 8 | Retire 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 — held | 6 |
| 9 | (held separately) Repair the 11 drifted prod appliedAt values. Production data write — separate PR, explicit approval, snapshot. | Proposed — held | 2b |
"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.
Deterministic replay, idempotent by construction: stable event id + timestamped sort key means a re-run overwrites, and a monotone pointer cannot regress.
| Source | Event | Basis |
|---|---|---|
createdAt / inquiryDate | inquiry_received / guest_card_received | event — verified accurate, present on 228/229 rows |
appliedAt | application_submitted | observed — labelling it event would launder the existing lie |
signedAt | lease_signed | observed, precision: 'day' |
Tour createdAt / history / cancelledAt | tour events | event |
Conversation lastMessageAt | pointers only, no per-message rows | event |
approvedAt | nothing | — |
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.
lastContactedAt already has a consumer), but no new UI column ships; the stale-lead digest reads the contact pointer without changing the page.appliedAt values in prod? Fixing forward is free; fixing the existing 11 is a production data write.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.
max(createdAt, updatedAt, appliedAt, approvedAt, signedAt) — refused on the receipt, not on principle: it produces a worse ranking than the dashes.decisionAt/decisionOutcome field pair in this arc. Right model, wrong time — the log closes the same gap without a field migration and three reader changes.Tour.confirmedAt — a third home for one instant (confirmation already lives in the tour history and in a partial notification surrogate). Tour completion is a genuinely missing lifecycle state and deserves its own ADR.stageHistory[] array on the row the list reads in bulk — unbounded growth on the hot read path.ActivityLogEntry: single global PK=PROP#GLOBAL partition, no prospect foreign key at all, closed maintenance/voice/renewal type union. A bounded reverse query of its 500 newest rows returns zero leasing events, and it could not join one back to a prospect if it had one.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.