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
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.
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.
| Property | Asking price comes from | Which units are available comes from | How fresh, as of 3 Sep |
|---|---|---|---|
| Camellia | Rent 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 Station | Website 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 Group | Not 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.
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 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.
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:
| Where | Feeds | Safety check |
|---|---|---|
api/leasing/availability/route.ts:22-38 | an availability API | none |
dashboard/chat/tools.ts:874-898 | the dashboard assistant | none |
tools-leasing.ts:1302-1340 (buildVacantUnits) | Clara on text, email and phone | gated |
tour/process-tour.ts:1539-1583 | what Clara knows before she replies | gated |
voice/leasing-context-injection.ts:929-984 | what Clara says at the start of a call | gated |
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 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.
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.
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.
| Source | Window | Where 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.
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.
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.
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.
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.
Everything Option B breaks, plus visible failures and a silent disconnect from AppFolio.
properties/load-unit-detail.ts:253-255.pms/writers/rental-application.ts:1051-1058). The application simply stops updating in PropFlow. Nothing tells anyone.properties/trending-occupancy.ts:214,238 discards a unit reference it cannot resolve.tenants/load-tenant-detail.ts:196,405 renders unit: null.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.
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 available | 2 of 18 | 17 of 17 that replied |
| Rents correct to the dollar | 1 | 17 of 17 |
| "What do you have?" answer | named no apartment | all 18, with rents, by bedroom count |
| Unlisted apartment correctly declined | yes | yes |
| Exact matches at the 100% bar | 1 of 20 | 3 of 20 |
What still misses the bar, and why. The data problem is fixed. The remaining misses are three separate things, each ticketed:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
One consumer at a time, each with its own proof, riskiest last. Nothing moves in a batch.
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
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.
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.
Out of scope, and the build stops an accidental import.
What Camellia reads today, and what each step does to it:
| Camellia path | Reads today | After migration |
|---|---|---|
Clara on text / email / phone (buildVacantUnits) | the rent roll — u.marketRent || null | Same figures. Fresh roll → roll. Migrated last, behind its existing gate. |
Voice call opening (leasing-context-injection) | the rent roll, behind the quality gate | Same figures. Migrated last. |
| Tour pipeline preload | the rent roll, behind the same gate | Same figures. The copied logic already agrees with the original. |
| Availability API | the rent roll, ungated, with ?? 0 | Not a Camellia surface — no in-repo caller reads this route at all. |
| Renewals, digests, loss-to-lease | the rent roll and the lease | Untouched 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.
The shared answerer, one consumer migrated (the unguarded availability API), the fence, and 22 new tests. Full typecheck and lint clean.
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.