Real Estate / Data Pipeline

Brooklyn's whole four-unit universe is scored on the sourcing side. Of 336 four-family sales in a trailing year, 86 closed at or under $1.2M. That ratio is why the engineering went into finding rather than into a better model.
acq-radar pushes over a bearer token and never reads back. The request shape is a zod schema here and a hand-copied duplicate there, so neither app builds against the other and drift fails at the boundary instead of at runtime.
Each variable saves its source, fetch time and raw payload alongside the number. A figure that arrived from nowhere is visible in the interface instead of passing as a sensible default.
Groundwork is the buyer-facing half of a two-app pipeline for NYC small multifamily. Its sibling, acq-radar, holds the parcel graph and scores it at population scale. Groundwork is where a buyer works what comes back: a candidates board, a deal pipeline, owner outreach that has to clear a compliance gate before it can be sent, and an address-in profit and loss that streams each figure as the data behind it resolves.
It started inside Forge BI as a property workbench and outgrew it. A financial-planning product and a property acquisition tool were sharing a schema, a navigation tree and a deploy for no reason other than that being where the code was first written. I pulled it out into its own Next.js 15 app on its own database and replaced the import with a wire contract.
Brooklyn has 12,713 four-unit lots. Over a trailing year 336 of them traded, and 86 of those closed at or under $1.2M: roughly seven a month across the entire borough. The median four-family sale was $1,794,000, so a $1.2M budget is the 25th percentile of the market. Those three numbers decide the architecture. At seven qualifying trades a month, no amount of underwriting quality wins a deal you never saw, so the expensive problem is finding the property and the cheap problem is the math on it. The system is arranged around that asymmetry: search the whole borough at once, underwrite one address at a time.
acq-radar posts qualified parcels to a single ingest route behind a bearer token, keyed on BBL and idempotent, so a nightly run can replay without duplicating a row. Each push is recorded as an ingest run with a per-row created, updated or error status. The traffic is one way: Groundwork never writes back, so the sourcing side can be rebuilt without touching this app.
Paste an address and the grid fills in as each input resolves. The creation route streams NDJSON cell updates while the runner walks a dependency graph of variables, so the sheet populates in the order the data actually arrives instead of blocking on the slowest lookup. Every variable stores a provenance record beside its value: where it came from, when it was fetched, and the raw payload behind it.
An owner packet is built from the candidate's own facts and branches on who is buying, because an investor and a family looking for a home make different arguments to the same seller. Before a packet can be marked sendable it passes a compliance check that blocks any owner on New York's Department of State Cease and Desist list, warns when the copy of that list is over 35 days old, and pins the stated purpose and licence posture so nothing can present me as a broker.
/** DOS republishes monthly; 35 days allows for a late publication. */
export const CD_LIST_MAX_AGE_DAYS = 35;
if (candidate.onCdList === true) {
blockers.push(
`${candidate.address} is on the DOS Cease and Desist list. This owner has asked not to be solicited about selling, so no letter, call, or door knock may be sent.`,
);
}
if (cdListAgeDays > CD_LIST_MAX_AGE_DAYS) {
warnings.push(
`The DOS Cease and Desist list checked here is dated ${cdListDated}, ${cdListAgeDays} days old. DOS republishes monthly. Re-check the current list before sending.`,
);
}The block sits on the candidate rather than on the letter, so nothing downstream can route around it by rewriting copy. The second check is the one that is easy to miss: New York republishes that list every month, so a clean answer has a shelf life, and a check with no expiry quietly stops being true.
Pulling this engine out of Forge BI turned up four bugs that were all the same bug: a lookup ran, returned real data, and the value never landed on the variable it was meant to fill. Nothing threw. A mortgage tool returned a field called rate into a variable keyed interest_rate_annual, and the cell just stayed empty and read as a default. Two of them came across from the original app, where they had never surfaced. What caught them in the end was not a test, it was making every number carry where it came from, because a value with no provenance is visibly a hole and a value with a plausible default is not.
Moving the engine into its own app surfaced four bugs of one shape: a data tool ran, returned the right payload, and the value never reached the variable it was meant to fill. One returned a field named rate into a variable keyed interest_rate_annual. Nothing threw and nothing logged, and an empty cell is indistinguishable from a cell nobody has filled in yet.
I gave every stored value a provenance record holding its source, fetch time and raw payload, surfaced it in the grid, and added an explicit alias step so a tool's output field has to be mapped onto a variable key rather than matched by luck.
A number with no source looks exactly like a number with a sensible default, which is why this class of bug survives review. Recording where a value came from is not documentation, it is the check. Once every cell has to name a source, the cells that never got one are the bug report.
The property tooling grew inside a financial-planning product and shared its schema, its navigation and its deploy. Two products in one app meant every change to either had to be reasoned about against both, and the sourcing side had nowhere to push to.
I split it into its own app on its own database and made the boundary a bearer-token HTTP contract validated by a zod schema, copying the request shape into the sender by hand rather than publishing a package.
Copying a type across a boundary looks like the worse option until you count what a shared package costs: a version to bump, a build to couple, a release order to remember. A copied shape with a validator on the receiving end fails loudly on drift and lets each side ship on its own schedule. I would not make that trade for ten call sites. For one, it was right.
The first scoring pass treated the physical criteria as pass or fail: eight feet of side gap, thirty feet of lot width, three thousand square feet of unused development rights. Measured against the real 12,713 parcel set, a seven foot gap scored identically to no gap at all, and the unused floor area filter cut a whole neighbourhood out of the shortlist.
I turned every geometric criterion but one into a graded curve, and kept a single hard floor on lot width, moved down to twenty feet where a lot is genuinely unusable rather than merely narrow. Ninety five percent of otherwise eligible parcels clear it.
A threshold on a continuous quantity throws away the ordering, and the ordering was the entire product. Filters feel safer than scores because they are easier to explain, and each one silently removes properties you would have wanted to see without ever telling you which.