01 · The Plan
Rowan’s recommendation: ship it internally
Rowan builds and ships this internally rather than briefing an outside developer. We already understand 60–70%+ of the tool, the mockup is already ~80% of a working product, and hiring out would burn 10+ hours just to brief and interview before any code is written. Building it in-house is faster, comes at no external cost to Compact Cottages, and produces clean documentation for a clean future hand-off (V2). Once it’s live and proven, V1 lands within 4–5 weeks, aligned with the new-website launch.
The Compact Cottages team’s priority is a working zoning tool on the new website, not a perfect data pipeline. So we aim to publish at ~60–70% coverage and ~90% data accuracy, then iterate: get it out there and see how people use it. The data-automation work (Tier 2) comes after.
Finalize the front-end
Make the existing tool an accurate sales funnel on the new site.
What ships
- Address intelligence: fuzzy address match, look-up by PIN, and by owner name, with an early “did you mean…?” step.
Two surfaces, one tool
- The website tool (the launch): house-plan matching that compares the max buildable size against the Compact Cottages catalog and shows every fitting model (“up to X sq ft” filters); accurate data display; and a clear disclaimer that the numbers may not be 100% accurate, inviting the visitor to call our team to discuss their exact situation.
- The realtor plugin (already ~90% built): ship it as an embeddable plugin; the full integration onto a specific realtor’s site is prepared as a separate next track, with its own process and brief.
Data & markets
For Tier 1 the data is entered manually / semi-manually from Scott’s sheets (Tier 2 automates this). Launch markets: Asheville, Buncombe, Durham.
MCP / API enablement
The goal: give Claude API access to the backend so Scott and Steve can maintain the data themselves, with no developer in the loop.
How it works
- Scott perfects his numbers in Excel (what he already does) and exports a clean CSV.
- Claude, via an MCP + the API, pushes that CSV into the database (the right calculations, uses and datasets), with no manual re-typing.
- A human always validates before anything goes live.
- Clean, standardized CSVs for the three datasets: parcel + zoning, permitted uses, and the new dimensional standards.
- A small fresh test dataset — a new county / jurisdiction with ~10–20 lots — so we can validate the API + MCP access end to end before loading full counties.
The instinct to “just read from the CSV” is honored without slowing the tool down: Scott’s validated CSV is the source, but the live lookup still hits the fast Postgres database on DigitalOcean. The CSV is pushed into the DB via the API (Tier 2); it is never read live at query time. Two of the three datasets already live in DigitalOcean and stay there; the new dimensional-standards data joins them.
02 · Executive summary
What the tool is, and the two mockups we built
Compact Cottages’ “Zoning Search” is a three-app system, a NestJS API, an admin panel, and a public lookup site on DigitalOcean, built by a previous Upwork development vendor. How it works: a visitor types an address, the system finds the parcel, looks up its zoning district, and returns what that district allows.
To explore the tool’s potential as a sales and lead-generation engine, Rowan built two initial mockups off the same backend:
- A homeowner zoning tool (see the mockup), embedded on the new website. Where the original tool just returns raw data, a visitor now types an address and gets a clear, visual answer: can I build an ADU, what size, and which Compact Cottages models fit.
- A realtor plugin (see the mockup): the same lookup as an embeddable widget for a realtor’s or land-listing site, referring prospects back to Compact Cottages.
03 · Current state
What exists today, in plain English
- A parcel database that works. 337,812 parcels across three counties (Buncombe, Durham, Henderson), built by Scott from county data. Fast, reliable address lookup.
- A rules layer that’s only ~5% built. The zoning knowledge lives in Scott’s “Zoning Translator” workbook; only about 2 districts have their dimensional numbers loaded into the app so far (more in Coverage).
- Three front-ends, one backend. The original vendor lookup (zones-site) plus Rowan’s two mockups, the homeowner zoning tool and the realtor plugin, all hitting the same endpoint.
- Held together by hacks. A 3.5 MB static address file for fast search, and the math computed in the visitor’s browser rather than on the server.
- Security to fix before public launch. The API is public with no login (owner data is exposed), and the admin password silently resets to
password123on every restart (see Limits).
The component map
Eleven moving parts. Three DigitalOcean apps + a managed Postgres are the previous vendor’s core; the consumer funnels (the zoning tool and the realtor widget), the external map/imagery, the AI copy layer and the static address index are Rowan-built mockups/additions on top. Component names link to their live page.
| # | Component | What it is | What it does | Connects to |
|---|---|---|---|---|
| 1 | zones-api zones-api-94c4p .ondigitalocean.app | NestJS 10 + TypeORM, DO App Platform, single instance | CSV→Postgres parcel ingestion; stores the zoning-rules matrix + calculated-field definitions; serves search/property endpoints (public GET /search, /properties/:id, /properties/by-dataset/:id) and admin CRUD (JWT) | Postgres; consumed by 2, 3, 4 |
| 2 | zones-admin zones-admin-ok6tv .ondigitalocean.app | React SPA on DO · login admin@example.com | Authoring: upload/activate datasets; edit the 23-column Yes/No/Maybe matrix cell-by-cell; build dimensional formulas token-by-token in the Formula Builder manual | zones-api (JWT, 24 h TTL, in-memory token) |
| 3 | zones-site zones-app-baprh .ondigitalocean.app | React/Vite SPA on DO | Public generic lookup: search by address / PIN / owner name; permitted-use pills + client-computed numbers. No map, AI, or verdict. ⚠ Its API client’s baked-in default base URL is a stale dev host (zones-api.dev-stage.fyi) — confirm what the DO env actually sets | zones-api — the same GET /properties/{id} as the ADU tool |
| 4 | Internal ADU tool Rowan mockup compactcottages.com/ can-i-build-an-adu.html | One 1,689-line hand-written HTML/vanilla-JS file on the marketing site (Cloudflare) — a Rowan-built mockup, not part of the delivered app | The consumer funnel Rowan built: offline autocomplete → parcel fetch → client-side verdict → satellite fly-in + placement study → CC model fit → AI prose → lead CTA funnel | zones-api GET /properties/{id}; Buncombe ArcGIS; ESRI tiles; /api/adu-copy |
| 5 | B2B / realtor plugin Rowan mockup compact-cottages- lot-widget.pages.dev | Rowan-built embeddable widget (the realtor lot widget) — the 3rd front-end | Same address → verdict lookup, framed for a listing agent to embed on a partner / realtor site; shows what CC (or a partner builder) can put on the lot | zones-api GET /properties/{id} — the same backend |
| 6 | Postgres (DO managed) | 10 entities; migrations-driven | parcels (77-column live view; full raw CSV row kept in JSONB metafield), datasets, the 5-table rules matrix, calculated_fields, users; dead buildings table | zones-api only |
| 7 | DO hosting | 3 DO apps + managed PG | Barry’s chosen speed layer — “the whole reason we went to DigitalOcean was because it was fast” | — |
| 8 | External map / imagery external Rowan addition | Buncombe County ArcGIS FeatureServer + ESRI World Imagery | Real lot geometry (parcel polygon by pinnum, token-free) for the fly-in and to-scale placement study; three-tier fallback cinematic → static still → abstract SVG. Not Google Maps — the transcript’s “Google Maps API” claim is contradicted by code | Called from the ADU tool in-browser |
| 9 | AI copy layer external Rowan addition | Cloudflare Pages Function functions/api/adu-copy.js → Anthropic API, claude-opus-4-8, structured outputs | Writes warm prose around the deterministic verdict; the JSON schema has no numeric fields, so AI cannot alter numbers; fails safe to deterministic copy | Called after the verdict renders |
| 10 | Upstream data pipeline manual | Scott’s ArcGIS Pro → Excel → CSV process + the Zoning Translator workbook | Produces the per-county parcel CSVs and ALL zoning knowledge (see Coverage) | Feeds zones-admin uploads |
| 11 | Static side-assets Rowan mockup workaround | buncombe-addresses.json (3.5 MB autocomplete index), cc-pricing-data.js, DATASET=“Feb 12 2026” label | A Rowan workaround to show addresses faster in the front-end while a user is searching (the live /search was too slow) — hand-regenerated snapshots that go stale silently on every data refresh. Not pre-existing. | Baked into the ADU page |
zones-site, and the B2B realtor widget all call the identical, un-authenticated GET /properties/{id}; GET /search and /properties carry no auth, so parcels and owner data are openly enumerable.zones-admin; the matrix (global) and calculated_fields (per-dataset) never sync, and the 3.5 MB address index is hand-regenerated on every refresh.new Function() — no server-side evaluator exists. zones-api just joins rules to parcels by the exact string zoning_district_location.zones-site lookup, and a B2B realtor lot widget (a Rowan mockup; prospects on a realtor’s site use it and its output refers them to Compact Cottages) — which all hit the same un-authenticated GET /properties/{id} on zones-api (NestJS, red). The ADU tool separately calls Buncombe ArcGIS, ESRI imagery, and a Cloudflare function → Claude for prose (slate, external). The amber hand-authored spine is the fragile part: the Translator workbook and Scott’s pipeline are manually re-typed into zones-admin, which writes to the API over JWT. Source: MASTER-SPEC-FINAL §3–§6, observed 2026-08-19 snapshot.04 · Current flow
Address in → verdict out, step by step
The consumer path through the internal ADU tool — the flow that matters commercially. By default the delivered app makes exactly one call (the parcel + rules fetch); the lot geometry, satellite fly-in and AI prose are Rowan additions in the mockup to make the result more UI-friendly. The verdict never leaves the browser. In the swimlane and the six stages below, 0–2 are the existing tool and 3–6 are Rowan’s additions.
The delivered app makes one API call — GET /properties/{id} (the parcel and its rules). Everything else in the flow — the ArcGIS lot polygon, the ESRI satellite fly-in, and the Claude prose call — is a Rowan addition inside the mockup so the UI is friendlier. Stages 0–2 are the existing tool; stages 3–6 are Rowan’s. (The earlier “3 API calls / Google Maps” note was the prior framing; the imagery is ESRI, not Google.)
GET /properties/{id} (parcel + rules). The lot polygon, satellite fly-in and AI prose are Rowan additions in the mockup for a friendlier UI.- Autocomplete. existing entry — Rowan swapped in a static
buncombe-addresses.jsonindex (every Buncombe parcel, ~3.5 MB) searched in-browser, 0 API calls (the live/searchwas too slow). Picking a suggestion navigates to?id=<parcelId>. - CALL 1 — parcel + rules. existing tool —
GET /properties/{id}returns parcel fields + the 23-column 0/1/2 matrix row (matched on exact stringzoning_district_location) +calculated_fields[]. The one and only property-detail endpoint; public, no auth. This is the only call the delivered app makes. - Verdict computed in the browser. existing tool (Rowan enhanced the display) —
buildVerdict()reads ADU permission from the matrix row and evaluates the dimensional formula vianew Function(). ADU cap = stored formula, else fallbackmin(0.70×HLA, 800); the displayed cap is always rounded down to the nearest 10. No server-side evaluator exists anywhere. - Lot geometry. Rowan addition — Buncombe ArcGIS FeatureServer query by
pinnum→ GeoJSON polygon (never blocks the verdict; on failure, an approximate rectangle). - Satellite fly-in. Rowan addition — MapLibre GL over ESRI World Imagery, with a 3-tier fallback: cinematic zoom → single static ESRI
exportJPEG + SVG overlay → abstract illustration. - AI prose. Rowan addition —
POST /api/adu-copy(non-PII bundle) → Cloudflare Function → Anthropicclaude-opus-4-8(prose-only schema). Swaps the copy after the verdict is on screen; any failure leaves the deterministic copy. - Optional lead capture. Rowan addition — a soft form POSTs to
CC_DATA.endpoints.submit, currently a no-op stub; webhook/CRM wiring is an open action item.
GET /properties/{id}, with the verdict computed in the browser); stages 3–6 are Rowan additions in the mockup — the lot polygon, satellite fly-in and AI prose — which only decorate the verdict and never block it or change the numbers. The green dashed line separates the existing tool (left) from Rowan’s additions (right).Scott reads a UDO → types it into the Zoning Translator workbook → someone re-types it into zones-admin, either as matrix cells (one keystroke per cell: 0/1/2 — no paste, no bulk import) or as Formula Builder token trees (one field = one zone = one constant). Parcel CSVs go ArcGIS → Excel → CSV → admin upload → Activate.
05 · Rule systems
Three artifacts hold the zoning knowledge — none of them sync
The Zoning Translator workbook is the spec; the app’s two rule systems are partial manual re-keyings of it that have already drifted apart. All three join to parcels through one exact text string, zoning_district_location (e.g. “RM8 Asheville”, “R-2 Buncombe County”).
min(0.70×HLA, 800). Per-dataset; Durham 1 real + 1 empty, Henderson placeholder only.DRIFT RISKS — THREE SEPARATE COPIES, SYNCED ONLY BY RE-TYPING
- Scope mismatch. The matrix is global & unversioned — editing one cell changes every county instantly and silently, with no audit trail — while the formulas are per-dataset, so re-importing a county as a new dataset orphans every formula and forces a full re-author.
- Stale CSV help-text. The admin’s “Upload Zoning Rules CSV” step documents a flat column contract (
min_lot_size_sqft, adu_allowed, …) whose exact columns were dropped by the matrix-refactor migration. The text describes the pre-refactor schema; treat bulk rules import as absent (confirm with Scott). - Globally-unique
code. A live DB constraint (migration1767891498570) makescodeunique across all jurisdictions, so the model literally cannot hold “RS8 (Asheville)” and “RS8 (Durham)” as separate rows without concatenating jurisdiction into the string. The rebuild must drop this live constraint, not just add a new key.
zoning_district_location (bottom bus). The funnel narrows coverage 18→14→2; the red badge flags the three ways the copies drift. Figures are the observed 2026-08-19 live snapshot.The reconciliation, made crisp
- The Translator is the spec.
2026_08_11_Zoning_Translator.xlsx, 39 sheets: a master Uses matrix of 219 rows × 23 uses (0/1/2; “2 = Maybe” means discretionary review) spanning 18 jurisdictions across 4 counties, plus 18 per-jurisdiction Dimensional sheets holding the authoritative numbers, and a plain-English ADU rule summary. Nothing in the app reads it directly. - The Matrix (System 2) = the Translator’s Uses sheet, re-typed for 14 of 18 jurisdictions (~120 district rows). Verified: the 23 columns match the workbook’s 23 uses exactly, and a live parcel returns the same 23
flattened_values. It is global and unversioned — a cell edit changes every county instantly, silently, with no audit trail — and a live DB constraint makes each districtcodeglobally unique, which is why “RS8 (Asheville)” and “RS8 (Durham)” can’t coexist without concatenating the jurisdiction into the string. - Calculated Fields (System 1) = the Translator’s Dimensional sheets, re-typed as formulas for essentially 2 districts of 1 county: Buncombe (dataset 8) holds 15 fields — RM8 (6) + HB (5) district constants, 3 jurisdiction-level flag-lot widths, and the one genuine calculation, the Asheville-wide ADU cap
min(0.70×HLA, 800). Durham: 1 real formula + 1 empty placeholder. Henderson: placeholder only. Per-dataset — re-importing a county orphans its formulas. (The round-to-10 seen in older docs is not in the stored field — it lives only in the website’s presentation layer, applied to every displayed ADU cap.) - The conceptual seed (
ZoningSearchFormulaExamples.xlsx) shows the intended design: the Uses matrix gates which dimensional fields even display — realized today via each field’svisibilityexpression. - A vestigial third system exists in documentation only. The admin Home screen still documents an “Upload Zoning Rules CSV” step with a flat column contract (
min_lot_size_sqft, adu_allowed, …) — but those exact columns were dropped by the matrix-refactor migration. The help text is stale; treat bulk rules import as absent (confirm with Scott). - The scoping mismatch is a live trap: global/unversioned matrix vs per-dataset formulas. Edit a cell → every county changes silently; re-import a county → re-author every formula.
06 · Coverage & data reality
What actually works, county by county
This grid resolves the older “Buncombe only” and “5 markets” claims — prefer it. Three counties are loaded and searchable; buildability output exists almost nowhere yet. Launch markets: Asheville, Buncombe, Durham (the active-selling markets); other counties follow.
We’re standardizing zoning rules for potentially 100+ counties — being 100% accurate everywhere isn’t realistic, and the call (Barry) is that ~90–92% is the right target for launch. Where a value is missing — e.g. whether a lot has public water/sewer, which changes the minimum lot size — we use the most permissive assumption and show a plain caveat, e.g. “8,000 sq ft minimum (assuming city water & sewer).” AI can raise accuracy over time (in a live test it inferred public sewer correctly on 5 of 5 addresses) — a Phase-2 upgrade, not a launch blocker. Scott stays in the loop so the planner’s judgment lands where it matters.
Where the data comes from — the three datasets
Every answer the tool gives is assembled from three data sets, plus live geometry and AI text:
1 · Parcel + zoningEvery property, with its zoning district. Scott builds it from the county tax-office parcel file, joining zoning on (by PIN, or by map polygon where there’s no PIN). Already in DigitalOcean.
2 · Permitted usesThe Yes / No / Maybe grid — which of the 23 CC housing types each zone allows. Already in the DigitalOcean backend.
3 · Dimensional standards newThe numbers: min lot size, setbacks, max ADU size, lot yield. Scott’s new spreadsheet → clean CSV → pushed into the DB (Tier 2). This is the piece being built now.
Plus: lot geometry & dimensions come live from ArcGIS (not stored); building size / heated area is appended from public real-estate files; the satellite fly-in is a separate imagery API; and Claude writes the plain-English summary around the numbers.
Coverage & Data Reality · observed 2026-08-19 snapshot
Pipeline coverage by county
Four columns show how far each county has traveled through the pipeline — from rules authored in the Translator, to parcels loaded and searchable, to a live use matrix, to the district-specific dimensional formulas that produce a real buildability answer. The last column is where the work thins out.
| County / jurisdictions | Rules in Translator | Parcels loaded (ACTIVE) | Matrix rows live | Dimensional formulas live |
|---|---|---|---|---|
| Buncombe + Asheville, Biltmore Forest, Black Mtn, Montreat, Weaverville, Woodfin | ✓full | ✓134,420dataset 8 · “Feb 12 2026” | ✓7 jurisdictions | ⚠15 fields = 2 districtsRM8, HB + jurisdiction flag-lot widths + Asheville-wide ADU cap |
| Durham City-County (unified) | ✓full | ✓132,268dataset 10 | ✓1 jurisdiction11 districts | ✗1 real + 1 placeholder |
| Henderson + Flat Rock, Fletcher, Hendersonville, Laurel Park, Mills River | ✓full | ✓71,124dataset 9 | ✓6 jurisdictions | ✗placeholder only |
| Orange — unincorporated county area | ⚠partial draft53-row Permitted-Uses sheet + dimensional + ADU rules in workbook | ✗not loadedRegrid sample only | ✗— | ✗— |
| Orange — municipalities Carrboro / Chapel Hill / Hillsborough | □WIPdistricts named (rows 142–219), 0/1/2 cells blank | not loaded | — | — |
The headline scar: district-specific dimensional numbers — the ones that yield a real buildable-size answer — exist for exactly 2 of ~120 live districts. Everything left of that column looks healthy; the value only lands in the last column.
Footnote. “Raleigh” = 1,109 parcels INSIDE the Durham dataset, a jurisdiction label — not a separate market. Parcel counts and the Durham/Henderson calc-field counts are observed-on-2026-08-19 admin-UI snapshots; confirm with Scott when precision matters.
Buncombe’s 15 calc fields, dataset id 8, the 23-column matrix, and the two anchor parcels are backed by live API JSON (high confidence). The parcel counts (134,420 / 132,268 / 71,124) and the Durham(2) / Henderson(1) calc-field counts come from a one-time live admin-UI walkthrough on 2026-08-19 — treat them as observed-on-that-date snapshots, not immutable facts, and re-read them from the admin when precision matters. Also: the Buncombe COMPOSITE CSV has 134,868 data rows vs 134,420 loaded active parcels (the metadata sheet quotes 134,064) — the CSV is a parcel×building join, one row per building, so row counts should not match parcel counts exactly.
“Raleigh” is not a market. It is a jurisdiction label on 1,109 parcels inside the Durham dataset.
A newer Buncombe refresh sits unused. A “2026 07 20 … ZONING MODIFIED” upload exists but was left archived / un-promoted — an operational gap. Live Buncombe data is still the Feb-12 snapshot, and the public DATASET label still says “Feb 12 2026.”
How the data gets made — Scott’s 3 stages, per county manual
Stage 1 · ArcGIS Pro: spatial/tabular joins of zoning polygons and tables onto parcels; hand-minted special districts (Asheville “RS2 7F”-style codes from the sevenf flag, Durham “RS-8/RS-10 UrbanTier,” Chapel Hill CD tags). Stage 2 · Excel: rename / concat / derive — Zoning District Location = District & " " & Location: the join key is literally an Excel concatenation; PIN manually renamed to Pin every time. Stage 3: save-as CSV → admin upload → Activate. Buncombe’s production CSV: 105 columns, 134,868 rows. The API keeps the whole raw row in JSONB metafield and promotes 12 mapped columns.
Known quirks that must survive into the rebuild
- Heated-living-area semantics differ per county — Buncombe/Henderson raw SqFeet includes garage (needs the separate finished-area table); Durham’s is already house-only.
- Free text inside numeric cells — “35 (20 steep slope)”, “min 24, max 265”, “10, per State septic rules”; overlays (Steep Slope, Pedestrian Area, 7F, UrbanTier, Conservation District) are encoded as string annotations.
- Durham split-zoned parcels (2–3 districts on one parcel) have no answer in the single-exact-match model.
- Known upstream errors — Asheville
sevenfY/N flips; Flat Rock’s CITY split across 3 fire districts, patched in Excel. - Durham has no Year Built (the county charges for CAMA data); and a
YearBuiltheader mismatch silently nullsyear_builton live parcels.
Regrid — the scaling lever external
CC already holds samples of (a) Regrid’s structured zoning-rules tables (26 columns: permitted uses as-of-right/conditional + dimensional standards; sentinel values -5555/-9999 mean “no data” and must be nulled on ingest; no Orange file) and (b) Regrid’s national parcel product (187–215 columns per county: zoning join key, buildings, valuation, owners, lat/lon, FEMA flood, opportunity zones; STANDARD = 124 vs PREMIUM = 165 DBF fields — PREMIUM adds utility-availability and topography flags that map directly onto CC’s sewer/water-dependent min-lot-size permutations). Strategic read: Regrid can replace Stage 1 and most of Stage 2 for any US county and provide a first-draft rules layer to validate against; it does not capture CC’s 23-use / 0-1-2 discretionary nuance — the Translator remains the value-add layer on top.
07 · Limits
Why it doesn’t scale — exhaustive and evidenced
The dominant formula pattern is a single-condition, single-zone constant with an empty else: IF zoning_district_location == "RM8 Asheville" THEN 40 ELSE ⟨empty⟩. One field yields a value for exactly one zone, so full coverage costs N zones × M dimensions hand-built formulas. One district ≈ 25–30 discrete admin actions (1 rule row + ~23 matrix cells + ~8–10 token-tree formulas hard-coding the zone string and every constant; zero reuse). Smoking gun: six months in — Durham has 1 real formula, Henderson 0.
Rules authoring manual
- N×M hand-authored formulas (above) — the structural wall.
- Keystroke-per-cell matrix editing; no paste, no bulk fill; the documented “Upload Zoning Rules CSV” refers to a schema dropped by migration.
- Two rule systems with mismatched scoping — global/unversioned matrix vs per-dataset formulas; re-importing a county orphans its formulas; a matrix edit silently changes all counties with no audit trail.
- Three drifting copies of the same knowledge (Translator workbook → matrix → formulas), synced only by human re-typing.
The join
- Exact-string join everywhere (
code == zoning_district_location; every formula guard). The key is an Excel concatenation; any casing/spacing/suffix drift = silent no-match (observed:"RM8 Asheville"double-space; a leading-space sheet name). No validation that a parcel’s district has a rule row. - Zone-name collision across jurisdictions — Asheville RS/RM vs Buncombe R-#/OU; the same code means different things per county, the concatenated string is the only disambiguator, and the DB enforces this shape via the globally-unique
codeconstraint. Exactly Barry’s RS2-vs-R1/R2 problem, observed live. - Overlays as text, not data (7F, Steep Slope, Pedestrian Area, UrbanTier); split-zoned parcels unsupported.
Computation
- No server-side evaluator; three client evaluators. Numbers are computed in the visitor’s browser via
new Function(...); the ADU tool (vanilla JS), zones-site (visibilityRules.ts), and the admin preview (mathjs) independently re-implement the token-tree semantics — guaranteed drift, untestable centrally. - Numeric parcel data stored as text (
acreage,parcel_sf,heated_living_area) — ad-hoc client coercion, no units.
Ingestion, ops & latency
mode(Replace/Enlarge) captured but never read — every upload inserts; no true replace. No dedup of any kind — no intra-file, no cross-dataset, no unique constraint onparcels.pin; re-import duplicates every parcel. Multiple datasets can be active at once and search unions them.- In-memory job queue (custom EventEmitter, not BullMQ/Redis): jobs lost on restart, single-instance only; no upload size limit.
- Manual refresh choreography: re-upload requires activate + regenerate
buncombe-addresses.json+ bump theDATASETlabel — nothing automated. Live evidence: the Jul-20 Buncombe refresh sits archived while the public label still says Feb 12 2026. - Single admin user; no roles enforcement (see Security).
- Coverage: two of three loaded counties have no usable buildability output; Orange not loaded; even Buncombe’s dimensional coverage is 2 districts + flag-lot widths + one ADU formula.
- Latency hacks: live
/searchis too slow for typeahead (5–7 s) → the 3.5 MB static offline index shipped to every visitor; zones-site still exposes the slow path. Geometry/imagery depend on Buncombe’s free ArcGIS +pinnummatching — no guaranteed equivalent in other counties.
Security — close before any realtor or public expansion danger
- Public PII, enumerable.
/search+/properties/*are public and unauthenticated, return owner name / mailing address / deed / valuations, andparcels.idis a sequential integer — the full owner dataset is scrapeable by walking IDs. zones-site even offers owner-name search publicly. - No RolesGuard — any authenticated “viewer” can rewrite rules or delete datasets (role is checked inline on only 2 user endpoints).
- The admin password actively reverts to
password123.seedAdminUser()runs on every boot and resetsadmin@example.com’s password whenever the stored hash is invalid or simply doesn’t matchpassword123— a legitimately changed admin password is silently reset to the default on the next restart or deploy. This is stronger than “a default credential exists”: the system un-fixes itself. users.service.ts:47–76 · main.ts:84 - Misleading dead code on dedup. The ingest emits the warning “N duplicate pins within the CSV were skipped (last row wins within file)” — but the batch routine does a plain insert and always returns 0, so the warning can never fire and no last-row-wins behavior exists. A dev reading the warning string alone would wrongly assume dedup is implemented. csv-processor.service.ts:57, :236–255
- Hardening gaps: hard-coded JWT fallback secret; CORS wildcard; no rate limiting or helmet; DB TLS
rejectUnauthorized:false; Swagger mislabels public endpoints as bearer-secured. Also: zones-site’s client falls back to the stalehttps://zones-api.dev-stage.fyi/apibase URL whenVITE_API_URLis unset — audit whether that dev-stage host is still alive.
The technical shape (for the build)
The rest of this section is the engineering direction behind the plan (useful for the build and a future hand-off), not required reading to understand the plan itself.
In short: keep DigitalOcean + Postgres, move rule evaluation server-side, and store rules in a normalized schema keyed by (jurisdiction, zone) with a zone-alias map, so the same rule isn’t re-typed per district. New counties are onboarded by the workflow shown in Coverage (Scott’s CSV → AI draft → human validate → pushed to the DB), and the full schema + acceptance detail live in the Developer brief. The one visual worth keeping here is the data pipeline, today vs. target:
The data pipeline — today vs. target
Every refresh today is hand-driven; the target replaces the manual spine with a pull-and-validate flow that runs itself.
The join key is literally an Excel concatenation: Zoning District Location = District & " " & Location. Any casing / spacing drift (e.g. "RM8 Asheville") is a silent no-match.
One human interface point — a Refresh click. Rules authoring is never in the hot path; the public API reads only the typed Postgres model.
What survives, what gets rebuilt, what goes
Keep reuse
- DO hosting + Postgres
- Parcel ingestion pipeline shape
- pg_trgm search
- The ADU tool’s UX — fly-in with three-tier fallback, placement study, verdict layout
- The
/api/adu-copyAI layer — the best-built part of the stack (structured outputs, prose-only schema) - The Translator workbook as spec
Rebuild new
- Both rule systems → the schema above
- The three browser evaluators → one server engine
- zones-site → an internal/QA view (do not ship owner-search publicly)
- Auth & security, wholesale
Retire remove
- The 3.5 MB static autocomplete index (server typeahead instead)
- The Formula Builder
- The cell-by-cell matrix editor
- The stale help-text CSV contract
- zones-site’s stale
dev-stage.fyidefault base URL
08 · Developer brief
The build plan — near-term
Mission: finalize and ship the existing (~80%-built) tool as an accurate sales funnel on the new website — don’t rebuild it — then open the backend so Scott & Steve own the data via Claude + an MCP/API. Keep the DO/Postgres backbone and the existing ingest; add what’s missing.
Phase 0 · Secure the API before public launch
The parcel/zoning API is public with no auth today (owner data is enumerable) — gate it (or strip PII + make IDs non-enumerable). Also remove the seedAdminUser password-reset (it reverts the admin password to password123 on every restart) + the JWT fallback secret; add roles, rate limiting, CORS scoping, DB TLS verification.
Tier 1 · Finalize the front-end ~1–2 weeks · Barry’s priority
Address intelligence — fuzzy address match, look-up by PIN and by owner name, an early “did you mean…?” step (every parcel has a unique PIN even when the address is a placeholder). House-plan matching — compare the max ADU size against the full CC plan catalog and show every fitting model, with “up to X sq ft” caps (e.g. Durham flag lots ~1,200). Accurate data display + the “assuming city water & sewer” caveats. Ship for Asheville, Buncombe, Durham on the new site.
Tier 2 · MCP / API — Scott owns the data ~2–3 weeks · after Tier 1
Build an MCP + API so Scott’s validated CSV pushes into the DB — the right calculations, uses and datasets — with no manual re-typing. Claude drafts ~90% from the UDO for Scott to proofread; a human validates before anything goes live. This replaces the hand-entered backend formulas (the “days of programming” visibility dead-end). Prove it on a small test dataset (one jurisdiction, a handful of rows) end-to-end first.
Later · Scale & polish post-launch, market-driven
More counties (the same Excel → CSV → API flow); the AI sewer/water inference flag; the realtor / land-listing widget productionized with the referral model; and — if/when the client wants higher accuracy and scale — moving evaluation server-side onto the normalized rules schema (the “technical shape” above).
178 Fairfax Ave (id 408223) · RM8 Asheville · HLA 760 → ADU cap 532 exact / 530 displayed. 1717 Old Haywood Rd (id 403372) · RM8 · vacant → cap 0. Both backed by live API JSON.
What we hand the dev
- Live systems: public site
zones-app-baprh.ondigitalocean.app· APIzones-api-94c4p.ondigitalocean.app(Swagger/api,/api-json) · adminzones-admin-ok6tv.ondigitalocean.app· CC staging sitecompact-cottages-website.mitch-bb3.workers.dev· demos:cc-adu-tool-preview.pages.dev,compact-cottages-lot-widget.pages.dev, boardcc-zoning-briefing.pages.dev. - Repos / code:
github.com/Barry-Bialik-Projects(zones-api / zones-admin / zones-site); local read-only cloneexternal-code/compact-cottages/zoning-tool/; website + ADU toolgithub.com/rowan-build/compact-cottages-website— key files:can-i-build-an-adu.html(1,689 lines),tools/adu-entry-autocomplete.js,tools/data/buncombe-addresses.json,functions/api/adu-copy.js,tools/cc-pricing-data.js. - Data assets:
2026_08_11_Zoning_Translator.xlsx(the rules spec) ·2026_07_17_Data_Dictionary_and_Methodology_ZONING_SEARCH.docx(Scott’s pipeline) ·2026_07_24_metadata.xlsx(field crosswalk + counts) · county COMPOSITE CSVs (Buncombe 105-col, 134,868 rows) · Scott’s “methodology” Word doc (in the “consultant files” folder — his full ArcGIS→Excel→CSV process, the seed for the MCP) · Regrid samples (reference only — the data source is county tax files + ArcGIS, not Regrid) · archive: Asheville 28-district dimensional CSV, formula-list docx. - Specs / mockups: BRIEF-A (ADU lead magnet), BRIEF-B (realtor widget),
briefs/01-realtor-integration-brief.md; the master spec + analyses A–E behind this board. - Key code references for reuse decisions: search.service.ts:207 (string join) · csv-processor.service.ts (ingest — note the dead duplicate-warning path) · calculated-field.entity.ts (token trees) · migrations 1767787285027 (matrix refactor) and 1767891498570 (globally-unique code — the constraint the rebuild removes) · visibilityRules.ts + ADU tool :612–1036 (the evaluators being retired) · axios-instance.ts:8 (stale base-URL default) · adu-copy.js (AI layer to keep).
- People: Scott Adams (rules/data authority, AICP) · Barry Bialik (direction/decisions) · Steve Wall · Mitch (Rowan PM) · Mohsen (previous dev — some notes spell it “Mohsin,” same person; stays on the website, not this rebuild).
09 · Decisions & open questions
Where it landed — and what’s still open for Monday
Resolved Aug 19 calls
Decided across the internal + Scott / Steve / Barry calls
- Who builds it: Rowan, internally — no external dev.
- Scaling the rules: keep the fast Postgres DB; Scott’s validated CSV is pushed into the DB via the API/MCP — not read live off CSVs.
- Accuracy target: ~90–92% is fine for launch, using liberal assumptions (e.g. city water & sewer) with an on-screen caveat.
- Water/sewer: no data field exists — assume the liberal value + caveat. AI can infer sewer (5/5 in a live test); a Phase-2 flag, not launch scope.
- Building size / heated area: appended from public real-estate files (not native to parcel data).
- Lot dimensions: pulled live from ArcGIS geometry, not the CSV.
- Rules entry: stop hand-entering backend formulas (the “days of programming” dead-end); fill from Scott’s CSV via the API instead.
- Priority: front-end first — address / PIN / owner-name lookup is Barry’s #1.
- Launch markets: Asheville, Buncombe, Durham.
- Data source: county tax-office parcel files + ArcGIS — not Regrid.
Still open Monday +
To confirm on the Monday call & as we build
- Client go-ahead on the plan (internal build; Tier 1 front-end, then Tier 2 data) — the Monday ask.
- Rule changes (e.g. an ADU minimum jumps to 1,200 sq ft): handle via a CSV edit + re-push, not code — confirm the process with Scott.
- House-plan matching logic — which CC plans, and the “up to X sq ft” caps (Durham flag lots cap ~1,200).
- Split-zoned parcels (one parcel, two districts) and field-heading growth across 35+ municipalities — keep it manageable.
- Secure the API before public launch (it’s open today) — agree the timing.
- Feasibility-study pricing for the CTA — confirm current options before hard-coding.
- Realtor referral model (~5% on confirmed sales) — Rowan proposal, needs client sign-off.