One asking price

Every place PropFlow tells someone what an apartment costs should get that number from the same place. Today about ten of them decide for themselves, and two do it with no safety check at all.

1 September 2026, corrected 3 September, shipped 3 September · Live · source setting + one function + robot harness in production; Yale on website, Camellia and Willows on rent roll

Status, corrected 3 September

How it merged without a decision. The pull request carried the word HOLD in its title and body. That word does nothing: the repo's automatic-merge guard deliberately ignores title text (the rule was considered and rejected in the guard's own notes). The two things that actually hold a pull request are opening it as a draft or applying the hold-for-review label. The session did neither, so the guard armed the automatic merge seven minutes after the pull request opened, and the merge fired seven minutes after the review bot approved. The build that followed went to production twenty minutes later.

What is live because of it. Exactly what the proof of concept described, and nothing more:

What did not change. Every surface a prospect actually hears from (Clara on text, email and phone, the tour pipeline, the call opening) still picks its price the old way. Camellia's behavior is identical to before. Nothing was turned on at any property.

Where pricing and availability come from today

Read directly from production on 3 September. This is the fact base for the setting proposed below. "Rent roll" means the property's own report loaded into PropFlow; "website scrape" means PropFlow reading the property's public listing page.

PropertyAsking price comes fromWhich units are available comes fromHow fresh, as of 3 Sep
CamelliaRent roll only. No website-scrape configuration exists, so the scrape branch is unreachable.Rent roll for vacancy status, plus a scrape of the public AppFolio listings page every 15 minutes that marks which vacant units are actually listed.Both current: rent roll applied and listings checked overnight.
Yale 25 StationWebsite scrape (On-Site listing, once a day at 6am Denver). The stored rent roll is a real CONAM report last loaded 29 April; no rent-roll feed has run since.The same daily website scrape: a unit is marked available when it appears on the listing, and unmarked when it disappears. Vacancy status itself still comes from the April rent roll.Scrape current (2 Sep, 6am). Rent roll over four months old, and no heartbeat exists for it at all.
The Willows (test)Rent roll only.Rent roll only; no listings scrape configured.Rent roll current.
Situs GroupNot in production yet. Fede, 3 Sep: their rent roll is completely out of date, and their website does not carry current availability either. Their staff floated a manually maintained source (a shared doc where the office sets what is available today).

Two things follow. First, the website already decides availability at two of three properties, so "where does availability come from" is as real a question as "where does the price come from," and the answer differs per property today. Second, the shipped rule (rent roll first when fresh) would only change Yale's answer if a fresh rent roll ever lands there. Nothing in production is in that state, so the rule is inert today, but it encodes a preference Fede has now said should be a per-client choice.

What went wrong

On 31 August a lead at Yale was quoted rent that was months out of date. Clara said $1,830 and $2,055. The property's own website, scraped that same morning, said $1,865 and $1,795. One of those numbers was too high and one was too low, and neither was ours to say.

PropFlow keeps two different prices for the same apartment.

The rent roll

The property's own report, loaded into PropFlow. This is the number we prefer, because it comes from the property rather than from us reading their website. Its weakness is that it can be missing, or simply old. Yale's is a real report from their management company — it is just stale.

The website scrape

Every morning PropFlow reads the prices off the property's public listing site. Its weakness is that not every property has one, and a scraper can quietly die. Its strength is that when it works, it is what a prospect would see today.

Neither source is right all the time, so someone has to choose between them. Right now that choice is made independently in about ten places, and each one made it slightly differently. Two of them made no choice at all — they read the stored rent roll and quoted it, however old it was:

WhereFeedsSafety check
api/leasing/availability/route.ts:22-38an availability APInone
dashboard/chat/tools.ts:874-898the dashboard assistantnone
tools-leasing.ts:1302-1340 (buildVacantUnits)Clara on text, email and phonegated
tour/process-tour.ts:1539-1583what Clara knows before she repliesgated
voice/leasing-context-injection.ts:929-984what Clara says at the start of a callgated

The gated ones already share a good safety check — assessLeasingQuality in src/lib/domain/leasing/leasing-data-quality.ts. But sharing a check is not the same as sharing an answer. The tour pipeline has its own copy of the price-picking logic (process-tour.ts:1506) carrying a comment telling the next person to keep it in lockstep by hand. That comment is the design flaw written down.

The rule

The 1 September rule, as shipped: ask the rent roll first. If it's too old, ask the website scrape. If that's too old as well, say nothing. One function answers for everybody, no new settings. This is what is live now for the one migrated consumer. The revision above keeps the one-function shape and the refusal, and replaces the fixed order with a setting.

Refusing to answer is a real answer, not a failure. Every one of these surfaces already knows how to say "let me confirm that and get right back to you" — that copy exists and is tested. The bug was never that we lacked a fallback. It was that a stale number looked like a good one.

The shape

getAskingPrices(propertyId)src/lib/domain/leasing/asking-price.ts

Hands back, for one property: a price per apartment, which of the two sources answered, and when that source last produced data — so a caller can say "as of Tuesday" instead of implying it is live. It returns null when it will not quote. A caller that turns that null into 0, a dash, or a quietly missing field has put the bug back.

An apartment can also be missing from the answer even when the property answered — that means we have a price for the building but not for that unit, and we quote nothing for it. That case was previously a zero.

How old is too old

Two windows, and only one of them is new. That is deliberate: a second, slightly different idea of "fresh" is how these things drift apart again.

SourceWindowWhere the number comes from
Website scrape 48 hours reused PRICING_STALENESS_MS, already in leasing-data-quality.ts:49. The scrape runs daily, so 48h forgives one missed run and still catches a dead scraper. The proof of concept imports it rather than copying it, so the two can never disagree.
Rent roll 7 days new — the only one RENT_ROLL_STALENESS_MS. Measured off a heartbeat that already exists: every time a rent roll is actually applied, apply-entity-sync.ts:921 writes a dated record of it.

Why seven days. A rent roll is not a live feed. It is a report the property emails in, which PropFlow then applies. Healthy properties send one at least weekly, so a seven-day window never blocks a working pipeline. Older than that means no new report has arrived in over a week — at which point the stored figures are a guess about today's asking price, and the scrape (or silence) is the safer answer. Tighter than seven days starts refusing over ordinary weekend and holiday gaps. Looser stops catching the Yale case this work exists for.

One intentional change of preference (superseded 3 September by the setting above)

Decision: what to do with Yale's old rent roll

Yale's stored rent roll is a genuine report from their management company that has gone out of date. The question is whether to leave those rows alone, wipe the rent figures out of them, or delete the unit rows entirely.

In plain terms

We have a stale price list for 112 apartments. We can leave it and teach the system to notice it's old; we can erase the prices but keep the apartments; or we can throw the whole list away. The third one sounds cleanest and is the most dangerous — a lot of the app looks up an apartment by name and quietly gives a wrong answer when it can't find one.

Option A · Recommended

Keep the rows. Let the age check do the work.

Breaks nothing. This is the only one of the three with no identified damage.

The decisive argument is that we have to build the age check anyway. Every property's rent roll can go stale, not just Yale's — that is the entire point of the window above. Once the check exists, Yale's old rows are already handled: the resolver stops quoting them and moves to the scrape, automatically, with no data surgery. Deleting or blanking would be solving by hand a problem the code now solves by itself, and would do so destructively.

Everything that reads those rows for reasons other than price keeps working untouched: occupancy is decided by Unit.status, not by rent (properties/computations.ts:34-64); the "how long has this been empty" signal leans on Unit.lastSyncedAt (insights/signals/vacancy-days.ts:107-118); bedroom and square-foot matching, tenant pages and unit links all resolve as normal.

Option B

Blank the rent figures, keep the apartments.

Silently turns real money into $0 in several places. Nothing errors; the numbers just get quietly wrong, which is worse.

The serious one is renewals. Seven places compute a resident's current rent with the same ladder — the lease amount, then the unit's rent, then market rent, then zero: data/store.ts:3470, data/renewal-for-tenant.ts:119, data/dynamo/leasing.ts:1348, renewal-auto-start.ts:615,670, renewal-cohort-walker.ts:553,765, temporal/activities/renewal.ts:3813. Blank the unit and, for any resident whose lease has no rent amount on it, a renewal offer gets computed against $0 current rent. Every renewal test still passes, because zero is a number.

Also affected: vacancy loss on the owner dashboard drops toward zero (properties/computations.ts:224), and the owner revenue and rent-gap figures skew low (api/property-reports/insights/route.ts:130-224).

Not affected: Clara's own quoting already writes || null rather than ?? 0 and filters the nulls out (tools-leasing.ts:1327,1334-1335), and the rent-roll table on the property page renders a dash for a missing figure.

Option C

Delete the unit rows.

Everything Option B breaks, plus visible failures and a silent disconnect from AppFolio.

  • Any saved or shared link to one of those units returns a hard 404properties/load-unit-detail.ts:253-255.
  • Any live AppFolio rental application pointing at a deleted unit is skipped on every sync from then on (pms/writers/rental-application.ts:1051-1058). The application simply stops updating in PropFlow. Nothing tells anyone.
  • A prospect interested in a deleted unit drops out of the occupancy countersproperties/trending-occupancy.ts:214,238 discards a unit reference it cannot resolve.
  • A resident living in a deleted unit loses all unit context on their page, not just the rent — tenants/load-tenant-detail.ts:196,405 renders unit: null.
  • The property's unit count shrinks, so the occupancy percentage moves for a reason that has nothing to do with occupancy.
Pick one:

Ruling, 2 September evening (Fede)

The gap that forced it, found in production the same evening. On 2 September Yale's website listed 19 apartments as available. Clara could offer 2. Prices were right (they came from the website); the list of units was wrong, because a unit only counts as vacant if the April rent roll said so, and the website scrape only marks units the roll already called vacant. Seventeen apartments that anyone could see on the website were invisible to Clara. So "website takes precedence" has to include which units exist to offer, not just what they cost.

What is being built now, dark:

Pointing Clara's live surfaces at the new function is the step after, per property, with the harness as the gate. Clara at Yale still shows 2 units until that step ships.

Shipped and measured, 3 September

The robot test, before and after. A script sends a real prospect email for every apartment on Yale's website, plus a trick question about an apartment that is not listed and an open "what do you have?" question, and grades each reply against the website at a 100% bar: offered, exact rent, exact date.

Before (2 Sep)After (3 Sep)
Apartments offered as available2 of 1817 of 17 that replied
Rents correct to the dollar117 of 17
"What do you have?" answernamed no apartmentall 18, with rents, by bedroom count
Unlisted apartment correctly declinedyesyes
Exact matches at the 100% bar1 of 203 of 20

What still misses the bar, and why. The data problem is fixed. The remaining misses are three separate things, each ticketed:

Decision: the leasing-source setting

How should a property say where its leasing truth comes from? This is the new decision that replaces "no new settings." Whatever the shape, it is stored the way every other per-property setting already is (one settings row per property, resolved portfolio → property once the portfolio tier exists) and read only by the one function.

In plain terms

Each property gets a switch that says "trust the rent roll," "trust the website," or "trust what our staff typed in." Clara and every screen ask one place and get one answer. Nobody else in the code ever looks at the switch.

Option A · Recommended

One explicit source per property, with the freshness check inside it.

The setting names the source: rent roll, website scrape, or manual. The function reads that source, applies that source's freshness window, and refuses to quote if the chosen source is stale. It never silently falls through to a different source. Camellia would be set to rent roll (what it does today), Yale to website (what it does today), and a Situs property to whichever they can actually keep current.

Why this one. It matches how Fede described the problem: the choice is per client and known at onboarding. It is one line of configuration, which is the right size for two properties. And a stale chosen source becomes a loud "we won't quote" that points at the exact source someone needs to fix, instead of a quiet switch to a number from somewhere else. The one cost: when Yale's real rent-roll feed finally connects, someone flips the setting. That is the one-place switch Fede asked for on 1 September.

Option B

A preference order per property.

The setting is a list, for example "website, then rent roll" at Yale and "rent roll only" at Camellia. The function walks the list and takes the first fresh source. More flexible, and it is what the shipped code already does with a hard-coded order. The cost is that a quiet fall-through is back: a property could switch sources without anyone deciding it, which is the failure this whole page started from. Reasonable later, once more than a handful of properties exist and the patterns are known.

Option C

Keep the fixed ladder that shipped. No setting.

Rent roll if fresh, else scrape, else refuse, everywhere. Zero configuration. It is wrong for the case Fede named: a client whose rent roll is available but untrustworthy for reasons age cannot see, and it has no room for a manual source at all. Listed for completeness.

Pick one:

Decision: a source the staff maintain by hand

Situs Group's staff suggested a document they update themselves with what is available today, because neither their rent roll nor their website is current. Fede is not yet sure this is the direction. The question is only whether the design should leave room for it now.

In plain terms

Should "the office types in today's availability and prices" be one of the sources Clara can be pointed at? Building it is separate from deciding it exists.

Option A · Recommended

Reserve the source now, build it only when a client commits to it.

The setting accepts "manual" from day one, and the function treats it like any other source: it has an as-of stamp and a freshness window (probably one business day), and it refuses when nobody has updated it. No editor, no import, no sheet connection gets built until Situs or another client actually chooses this. This keeps the interface honest without spending on a maybe.

Option B

Build a simple in-app editor now.

A table on the property page where staff mark units available and set an asking price, stamped with who and when. Gives Situs something to see during onboarding. Costs real build time before anyone has committed, and invites a second copy of the truth at properties that do have a working rent roll.

Option C

Not a source. Rent roll or website only.

Clients without a current rent roll or website get "let me confirm and follow up" on every pricing question until one of those is fixed. Simplest, and possibly the right pressure to put on a client, but it means Clara cannot quote at all for a client in Situs's stated situation.

Pick one:

Availability goes through the same door

Fede, 3 September: the one function answers price or availability. Today availability is assembled from three places: the vacancy status the rent roll wrote, a per-unit "is it listed" flag that either scrape may overwrite, and a separate quality gate that decides whether an empty list means "nothing available" or "we can't tell." Under the setting above, "which units can I offer" becomes a second question to the same function, answered from the same chosen source with the same as-of stamp. That is a bigger migration than price alone (the availability flag is written by two different scrapers and read by the tour pipeline and the call opening), so it lands as its own step after the price consumers, with its own before-and-after proof.

What is deliberately left alone

Renewals, loss-to-lease and the digest emails must never touch this. They price what a sitting resident already pays. An asking price — which may be a number scraped off a website — is not a lawful input to any of them.

The failure would be quiet. If a renewal ever priced off the shared answerer, a property whose rent roll went stale would start proposing renewals against a website number: inconsistent between residents, unexplainable to an operator, and a fair-housing problem the moment two residents are treated differently for a reason nobody can name. Every renewal test would still pass.

So the boundary is enforced by the build, not by a comment. src/__tests__/asking-price-fence.drift.test.ts follows the pattern the repo already uses for the calendar layer (calendar-provider-import-fence.drift.test.ts): it fails if any protected path imports the new module or reaches around it to the scrape store directly. Protected today: renewal-policy/, renewals/, renewal-orchestration/, domain/renewals/, digest/, stale-lead-digest/, reporting-digest/, plus pricing-loss-to-lease.ts, renewal-auto-start.ts, renewal-cohort-walker.ts and temporal/activities/renewal.ts. All verified clean today, so the fence starts green on real code rather than on an aspiration.

The fence carries a positive control that proves it can go red. That control earned its place on the first run: it caught the detector reporting a comment warning against the import as if it were the import.

Migration order

One consumer at a time, each with its own proof, riskiest last. Nothing moves in a batch.

1
The two unguarded ones.

The availability API and the dashboard assistant. These have no safety check at all today, so anything is an improvement and there is no existing behavior to preserve. the availability API is done in the proof of concept

2
The tour pipeline.

Deleting the hand-copied price logic and its "keep in lockstep" comment. Byte-identical for every property today, because the copy and the original already agree — that is the point of the comment.

3
Clara's leasing tools, then the voice injection.

Last, because they are what a live prospect hears. Both already sit behind the shared quality check, so they are the least broken and the most expensive to get wrong. The voice injection goes last of all.

Never: renewals, loss-to-lease, digests.

Out of scope, and the build stops an accidental import.

Camellia is safe, and here is why

What Camellia reads today, and what each step does to it:

Camellia pathReads todayAfter migration
Clara on text / email / phone (buildVacantUnits)the rent roll — u.marketRent || nullSame figures. Fresh roll → roll. Migrated last, behind its existing gate.
Voice call opening (leasing-context-injection)the rent roll, behind the quality gateSame figures. Migrated last.
Tour pipeline preloadthe rent roll, behind the same gateSame figures. The copied logic already agrees with the original.
Availability APIthe rent roll, ungated, with ?? 0Not a Camellia surface — no in-repo caller reads this route at all.
Renewals, digests, loss-to-leasethe rent roll and the leaseUntouched by construction — the build fence forbids the import.

The one way Camellia's behavior could change, stated plainly rather than buried: if Camellia's rent-roll loading ever stopped for more than a week, the answer becomes "we won't quote" instead of "here is a week-old number." That is the intended behavior and the reason the window exists. It is also observable in advance — the heartbeat is a dated record — so it can be alerted on rather than discovered by a prospect.

How it gets proved, each time

Rollout

1
Proof of concept on a branch. done

The shared answerer, one consumer migrated (the unguarded availability API), the fence, and 22 new tests. Full typecheck and lint clean.

2
The proof-of-concept pull request merged and deployed on 1 September. merged without a decision

It was meant to be held. "HOLD" in the title is not a hold; the automatic-merge guard armed it. See the status section at the top. It is live, harmless to every prospect surface, and not being reverted: the code is what the design asked for, and the setting below replaces its fixed order rather than undoing it.

3
Fede revised the rule on 3 September: source is a per-client setting, one function covers price and availability. This page was corrected and extended the same day. source setting decided 2 Sep evening
4
Next, once decided: add the setting and teach the one function to read it (Camellia = rent roll, Yale = website, no behavior change at either), then one pull request per consumer in the order above, then availability. Each carries its own before-and-after proof. Turning any of this on at a live property stays a separate step and a separate decision.
PropFlow Docs