Zoning Tool — Current State
& Rebuild Brief

What the “Zoning Search” system actually is today, the plan we’ve decided on, and the corrected, evidence-backed brief we hand the next developer.

Prepared by Rowan · 2026-08-19 · sources: live code + admin capture + 7 call transcripts + client data

337,812active parcels loaded & searchable (observed 2026-08-19)
3counties live — Buncombe, Durham, Henderson
2 of ~120live districts with dimensional rules built (RM8 + HB, Asheville)
100%of the rules layer is hand-typed — cell by cell, token by token

01 · Executive summary

A parcel database that works, on a rules layer that doesn’t scale

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, plus a separate, polished “Can I build an ADU?” tool (a Rowan-built mockup) embedded on the CC marketing website. A visitor types an address; the system finds the parcel, looks up its zoning district, and shows which of CC’s 23 housing products are allowed (Yes / No / Maybe) — and, for a sliver of districts, dimensional numbers like setbacks and max ADU size.

The parcel side works and scales: 337,812 active parcels across Buncombe, Durham, and Henderson counties (observed live 2026-08-19). The intelligence layer does not. Every allowed-use cell and every dimensional number is hand-typed into the admin, joined to parcels by an exact text string, and the math runs only in the visitor’s browser. Six months in, district-specific dimensional constants exist for exactly two Asheville districts (plus jurisdiction-level flag-lot widths and one Asheville-wide ADU-cap formula); Durham has one real formula, Henderson none.

The plan (decided Aug 19, across two calls — Rowan internal, then with Scott, Steve & Barry): Rowan builds it internally and ships the front-end first as a working sales tool on the new website (~Sept 7), publishing at ~60–70% coverage and ~90% data accuracy with plain caveats (e.g. “assuming city water & sewer”). We keep the fast DigitalOcean database; Scott perfects each county in Excel and Claude pushes his validated CSV into the DB via an API/MCP — giving Scott and Steve ownership of the data rather than a developer re-typing it. One backend powers three surfaces: the CC ADU page, a standalone tool, and an embeddable realtor widget. The Monday ask: confirm this direction.

02 · Current state

What exists today, in plain English

  1. A parcel database that works. Scott Adams (CC’s land planner, AICP) hand-builds a giant spreadsheet per county — county parcel maps into ArcGIS, zoning joined onto every parcel, cleaned up in Excel, saved as CSV. Someone uploads that CSV in an admin panel; it lands in a database on DigitalOcean. Three counties are live (counts observed in the admin UI on 2026-08-19): Buncombe 134,420 · Durham 132,268 · Henderson 71,124. Address lookup over these is fast and solid.
  2. A hand-typed rules layer that mostly doesn’t exist yet. Knowing a parcel’s zoning district is only half the answer — you also need “what does that district allow, and with what dimensions?” That knowledge lives in Scott’s “Zoning Translator” Excel workbook (18 jurisdictions across 4 counties, 23 CC housing products, a 0/1/2 = No/Yes/Maybe grid, plus dimensional-standards sheets). Only part of it has been re-typed into the app: the Yes/No/Maybe grid for 14 jurisdictions (~120 districts), and dimensional numbers for essentially two districts (RM8 and HB in Asheville) plus one ADU-size formula and flag-lot widths. Durham and Henderson parcels are searchable, but the tool can’t say much about what you can build there.
  3. Three front-ends. (a) The “Can I build an ADU?” page on compactcottages.com — polished, cinematic (satellite fly-in to your lot), gives a verdict, fits CC models, has an AI-written plain-English summary. Buncombe-only. This is a Rowan-built mockup. (b) The raw zones-site lookup app — the previous vendor’s public front-end: search by address, PIN, or owner; see data pills. No map, no verdict, and it exposes internal taxonomy and owner names publicly. (c) A B2B / realtor plugin (the realtor lot widget) that embeds the same lookup on a partner/realtor site — also a Rowan-built mockup. All three read the same property endpoint, GET /properties/{id}.
  4. Hacks holding it together. The ADU page’s instant address autocomplete is a 3.5 MB file of every Buncombe address baked into the page, regenerated by hand whenever data changes, because the live search API was too slow (5–7 s). The math (setbacks, ADU size) is computed in the visitor’s browser from formula “token trees,” not on the server.
  5. Real security problems. The property/search API is public with no login, IDs are sequential, and rows contain owner names + mailing addresses — the whole owner dataset is scrapeable. Worse than a “default password”: on every restart the API resets the admin account’s password back to password123 whenever the stored hash doesn’t match it — so even an admin who dutifully changes the password is silently reverted on the next deploy. Details in Limits.
Net

A working parcel pipeline and one good consumer funnel, sitting on a rules layer that is ~5% populated and 100% manual.

03 · Architecture

The component map

Eleven moving parts. Three DO apps and a managed Postgres form the previous vendor’s core; the consumer funnels (the ADU tool and the realtor widget), the external map/imagery, the AI copy layer and the static address index are Rowan-built mockups/additions layered on top — each is flagged below. Component names link to their live page.

#ComponentWhat it isWhat it doesConnects to
1zones-api
zones-api-94c4p
.ondigitalocean.app
NestJS 10 + TypeORM, DO App Platform, single instanceCSV→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
2zones-admin
zones-admin-ok6tv
.ondigitalocean.app
React SPA on DO · login admin@example.comAuthoring: upload/activate datasets; edit the 23-column Yes/No/Maybe matrix cell-by-cell; build dimensional formulas token-by-token in the Formula Builder manualzones-api (JWT, 24 h TTL, in-memory token)
3zones-site
zones-app-baprh
.ondigitalocean.app
React/Vite SPA on DOPublic 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 setszones-api — the same GET /properties/{id} as the ADU tool
4Internal 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 appThe consumer funnel Rowan built: offline autocomplete → parcel fetch → client-side verdict → satellite fly-in + placement study → CC model fit → AI prose → lead CTA funnelzones-api GET /properties/{id}; Buncombe ArcGIS; ESRI tiles; /api/adu-copy
5B2B / realtor plugin Rowan mockup
compact-cottages-
lot-widget.pages.dev
Rowan-built embeddable widget (the realtor lot widget) — the 3rd front-endSame 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 lotzones-api GET /properties/{id} — the same backend
6Postgres (DO managed)10 entities; migrations-drivenparcels (77-column live view; full raw CSV row kept in JSONB metafield), datasets, the 5-table rules matrix, calculated_fields, users; dead buildings tablezones-api only
7DO hosting3 DO apps + managed PGBarry’s chosen speed layer — “the whole reason we went to DigitalOcean was because it was fast”
8External map / imagery external Rowan additionBuncombe County ArcGIS FeatureServer + ESRI World ImageryReal 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 codeCalled from the ADU tool in-browser
9AI copy layer external Rowan additionCloudflare Pages Function functions/api/adu-copy.js → Anthropic API, claude-opus-4-8, structured outputsWrites warm prose around the deterministic verdict; the JSON schema has no numeric fields, so AI cannot alter numbers; fails safe to deterministic copyCalled after the verdict renders
10Upstream data pipeline manualScott’s ArcGIS Pro → Excel → CSV process + the Zoning Translator workbookProduces the per-county parcel CSVs and ALL zoning knowledge (see Coverage)Feeds zones-admin uploads
11Static side-assets Rowan mockup workaroundbuncombe-addresses.json (3.5 MB autocomplete index), cc-pricing-data.js, DATASET=“Feb 12 2026” labelA 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
Current Compact Cottages zoning-tool architecture — everything that exists today and how it connects A node graph. A visitor reaches three public front-ends: the internal ADU tool on the Cloudflare marketing site (a 1,689-line hand-written page, the consumer funnel), the zones-site public lookup, and a B2B realtor lot widget (a Rowan mockup used by prospects on a realtor's site, whose output refers them to Compact Cottages). All three call the identical un-authenticated GET /properties/{id} on the NestJS zones-api, which exposes GET /search and /properties with no auth (marked red). The verdict is computed in the browser via new Function(); the ADU tool also calls Buncombe County ArcGIS for the parcel polygon, ESRI World Imagery for the satellite fly-in, and a Cloudflare function that calls Claude for prose-only AI copy (all external, slate). The API only reads Postgres, joining rules to parcels by the exact string zoning_district_location; Postgres holds the parcels table plus JSONB metafield, the 5-table rules matrix, and per-dataset calculated_fields. A gold, amber-banded hand-authored spine at the bottom shows the Zoning Translator workbook and Scott's data pipeline being manually re-typed into zones-admin, which writes to the API over JWT. The static address index is hand-regenerated. CURRENT-SYSTEM ARCHITECTURE · EVERYTHING THAT EXISTS TODAY A working parcel-lookup stack with a hand-typed rules layer — three public front-ends converge on one un-authenticated endpoint. HAND-AUTHORED SPINE — manual re-typing, no sync between the two rule systems client-side calls to external services loads GET /properties/{id} GET /properties/{id} 3 front-ends → 1 endpoint exact-string join zoning_district_location authoring writes (JWT) manual re-typing CSV upload → Activate Static side-assets buncombe-addresses.json 3.5 MB address index · pricing js hand-regenerated Visitor prospective ADU buyer Internal ADU tool can-i-build-an-adu.html — 1,689 lines marketing site · Cloudflare consumer funnel Verdict computed in the browser token-trees evaluated via new Function() zones-site PUBLIC public generic lookup address / PIN / owner name no map / AI / verdict B2B / realtor plugin Rowan mockup realtor lot widget · on a partner site a prospect looks up a lot there, → output refers them to Compact Cottages Buncombe County ArcGIS parcel polygon by pinnum ESRI World Imagery satellite tiles / export Cloudflare Fn → Claude claude-opus-4-8 · AI copy prose-only (no numeric fields) fails safe to deterministic copy zones-api NestJS 10 · TypeORM · DO App Platform (single instance) PUBLIC · NO AUTH GET /search · /properties/:id · /properties/by-dataset/:id admin CRUD behind JWT (24 h, in-memory token) Postgres (DO managed) parcels — 77-col live view full raw CSV row kept in JSONB metafield rules matrix — 5 tables global · unversioned · 1 grp · 23 cols · 14 row-groups · ≈120 rows calculated_fields — per-dataset token-tree formulas Buncombe: 15 fields (2 districts RM8 + HB) · evaluated in-browser only Zoning Translator workbook 219 × 23 · 18 jurisdictions / 4 counties the spec — the real brain Scott’s data pipeline ArcGIS Pro → Excel → CSV per-county parcel CSVs zones-admin authoring SPA · React login admin@example.com 23-col Yes/No/Maybe matrix, cell-by-cell (0/1/2) Formula Builder, token-by-token PUBLIC · no auth hand-authored · manual re-typing external service consumer funnel data store
Three front-ends, one open door. The internal ADU tool, 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.
The hand-authored spine. The Zoning Translator workbook and Scott’s pipeline are re-typed by hand into 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.
The API only reads. The verdict is computed in the browser via new Function() — no server-side evaluator exists. zones-api just joins rules to parcels by the exact string zoning_district_location.
Diagram (a): the current architecture. A visitor reaches three public front-ends — the internal ADU tool (Cloudflare marketing site, the consumer funnel), the 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.

By default: Call 1 only

The delivered app makes one API callGET /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.)

Current end-to-end flow: one address to a build verdict, across four swimlanes A horizontal swimlane over four lanes — Browser, zones-api, External services, and AI. Nine numbered steps flow left to right: an in-browser static address index (0 API calls, the hack), a single GET /properties/{id} call returning the parcel, its 23-column 0/1/2 matrix row and calculated_fields, a verdict computed in the browser with new Function(), then post-verdict enhancements: an ArcGIS pinnum polygon query, an ESRI satellite fly-in with a three-tier fallback, a Claude prose call, and an optional no-op lead POST. A vertical line marks that the verdict is on screen before the map or AI run. CURRENT END-TO-END FLOW · ADDRESS → VERDICT Stages 0–2 = the delivered app · stages 3–6 = Rowan mockup additions. BROWSER visitor’s device ZONES-API public · no auth EXTERNAL ArcGIS + ESRI AI Claude via CF Function ◀ EXISTING TOOL (0–2) ROWAN MOCKUP ADDITIONS (3–6) ▶ The verdict is on screen after stage 2; the map, fly-in and AI (Rowan additions) only decorate it — none block it. 0 0 CALLS Address + autocomplete static index · “the hack” 1 CALL 1 Parcel + rules GET /properties/{id} row + calculated_fields 2 NO CALL Verdict computed in browser new Function() 3 CALL 2 Lot geometry ArcGIS pinnum → GeoJSON polygon 4 CALL 3 Satellite fly-in ESRI · 3-tier fallback 5 CALL 4 AI prose POST /api/adu-copy → Claude · swaps copy 6 CALL 5 · OPT Lead capture POST to submit no-op stub
By default: 1 call. The delivered app makes one API call — GET /properties/{id} (parcel + rules). The lot polygon, satellite fly-in and AI prose are Rowan additions in the mockup for a friendlier UI.
Verdict never blocked. Stage 2 commits the answer to screen; the Rowan additions (3–6) only decorate it — any can fail and the verdict still stands.
The AI can’t change the numbers. Claude (stage 5, a Rowan addition) writes prose only and swaps in after the deterministic verdict is already shown.
  1. Autocomplete. existing entry — Rowan swapped in a static buncombe-addresses.json index (every Buncombe parcel, ~3.5 MB) searched in-browser, 0 API calls (the live /search was too slow). Picking a suggestion navigates to ?id=<parcelId>.
  2. CALL 1 — parcel + rules. existing toolGET /properties/{id} returns parcel fields + the 23-column 0/1/2 matrix row (matched on exact string zoning_district_location) + calculated_fields[]. The one and only property-detail endpoint; public, no auth. This is the only call the delivered app makes.
  3. Verdict computed in the browser. existing tool (Rowan enhanced the display) — buildVerdict() reads ADU permission from the matrix row and evaluates the dimensional formula via new Function(). ADU cap = stored formula, else fallback min(0.70×HLA, 800); the displayed cap is always rounded down to the nearest 10. No server-side evaluator exists anywhere.
  4. Lot geometry. Rowan addition — Buncombe ArcGIS FeatureServer query by pinnum → GeoJSON polygon (never blocks the verdict; on failure, an approximate rectangle).
  5. Satellite fly-in. Rowan addition — MapLibre GL over ESRI World Imagery, with a 3-tier fallback: cinematic zoom → single static ESRI export JPEG + SVG overlay → abstract illustration.
  6. AI prose. Rowan additionPOST /api/adu-copy (non-PII bundle) → Cloudflare Function → Anthropic claude-opus-4-8 (prose-only schema). Swaps the copy after the verdict is on screen; any failure leaves the deterministic copy.
  7. 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.
Diagram (b): the consumer path from an address to a build verdict, drawn as a four-lane swimlane (Browser · zones-api · External services · AI). Stages 0–2 are the delivered app (one real call, 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).
The authoring path — how a rule gets into the system

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”).

The three rule systems: one spec and its two partial, hand-typed copies Three columns left to right. Column 1, the Zoning Translator workbook (sage) — the spec — holds a 219-row by 23-use matrix across 18 jurisdictions in 4 counties plus 18 dimensional sheets. Column 2, the app's global Zoning-Rules matrix (clay) — the Uses sheet re-typed — is 23 uses by 14 jurisdictions and about 120 districts, values 0 No / 1 Yes / 2 Maybe. Column 3, the app's per-dataset Calculated Fields (gold on amber) — the Dimensional sheets re-typed — is 15 formulas covering only 2 districts (RM8, HB) plus flag-lot widths and one Asheville ADU cap. Dashed gold arrows between the columns read manual re-keying, no sync. A bottom bus shows all three joining to parcels on the exact string zoning_district_location. A coverage funnel narrows 18 jurisdictions to 14 in the matrix to 2 with dimensional numbers, and a red badge lists three drift risks. THE THREE RULE SYSTEMS · ONE SPEC, TWO PARTIAL RE-KEYINGS system numbers 0 / 2 / 1 match this section’s prose The Translator workbook is the spec; the app’s Matrix and Calculated Fields are hand-typed copies of it — already drifting apart, synced only by human re-typing. SYSTEM 0 · THE SOURCE Zoning Translator workbook 2026_08_11_Zoning_Translator.xlsx · 39 sheets THE SPEC — the real brain = the source every other copy re-types Master Uses matrix — fully populated 219 rows × 23 uses · 0/1/2 per cell 18 jurisdictions · 4 counties Buncombe 7 · Durham 1 · Henderson 6 · Orange 4 + 18 per-jurisdiction Dimensional sheets — the authoritative numbers Canonical · lives outside the app SYSTEM 2 · IN THE APP Zoning-Rules matrix 5 tables · row_groups → rules → values USES, RE-TYPED = the Translator’s Uses sheet, re-keyed 0 No 1 Yes 2 Maybe (discretionary) 1 column group · 23 CC uses 14 row groups · ≈120 district rows Live for 14 of 18 jurisdictions Keystroke-per-cell · no paste / bulk import GLOBAL · unversioned · no audit SYSTEM 1 · IN THE APP Calculated Fields JSONB token-tree · Formula Builder NUMBERS, RE-TYPED = the Translator’s Dimensional sheets, re-keyed RM8 HB 2 of ≈120 districts filled 15 fields · Buncombe (dataset 8) 2 districts only: RM8 (6) · HB (5) + jurisdiction flag-lot widths (3) + 1 real calc: min(0.70×HLA, 800) Durham 1 real +1 empty · Henderson placeholder only PER-DATASET · runs in the browser MANUAL RE-KEYING no sync ✗ MANUAL RE-KEYING no sync ✗ THE JOIN all three → parcels, one string code == zoning_district_location e.g. “RM8 Asheville” · “R-2 Buncombe County” any casing / spacing / suffix drift = silent no-match COVERAGE FUNNEL · SPEC → APP 18 jurisdictions in the Translator 14 re-keyed into the Matrix (uses) 2 districts with dimensional numbers (RM8, HB) ⚠ DRIFT RISKS Scope split: matrix GLOBAL/unversioned vs formulas PER-DATASET Stale help-text: “Upload Zoning Rules CSV” contract dropped Globally-unique code — blocks RS8×2 jurisdictions Full detail below · bulk import treated as absent (confirm with Scott)
Translator = the spec. A 219×23 Uses matrix across 18 jurisdictions / 4 counties, plus 18 per-jurisdiction Dimensional sheets holding the authoritative numbers — the real brain that nothing in the app reads directly.
Matrix = its Uses sheet, re-typed. The 23 columns match the workbook exactly; live for 14 of 18 jurisdictions (≈120 district rows), values 0 No / 1 Yes / 2 Maybe. One shared, global, unversioned grid.
Calculated Fields = its Dimensional sheets, re-typed. 15 formulas for exactly 2 districts (RM8, HB) + flag-lot widths + one Asheville ADU cap 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

  1. 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.
  2. 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).
  3. Globally-unique code. A live DB constraint (migration 1767891498570) makes code unique 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.
Diagram (c): the three artifacts that hold zoning knowledge. The Translator workbook (sage) is the spec; the app’s global Matrix (clay, the Uses sheet re-typed) and per-dataset Calculated Fields (amber, the Dimensional sheets re-typed) are partial manual re-keyings joined by dashed “no-sync” arrows. All three join to parcels on the exact string 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 district code globally 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’s visibility expression.
  • 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.

Our data philosophy: ~90% accurate, on purpose

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 + zoning

Every 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 uses

The Yes / No / Maybe grid — which of the 23 CC housing types each zone allows. Already in the DigitalOcean backend.

3 · Dimensional standards new

The 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 coverage status across four pipeline stages
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.

Total ACTIVE parcels  ≈ 337,812  (observed 2026-08-19)
✓ done ⚠ partial / drift ✗ missing □ WIP

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.

Coverage grid for the current “Zoning Search” tool across 4 NC counties. Three counties are fully loaded and searchable (Buncombe, Durham, Henderson); Orange is a partial/WIP authoring draft, not loaded. Buildability output requires the rightmost column, which is populated for only 2 of ~120 live districts. Source: Master Spec §6 (observed 2026-08-19 snapshot).
Confidence note on the counts

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.

Two traps in the data

“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 sevenf Y/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 YearBuilt header mismatch silently nulls year_built on 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 N×M wall — the core finding

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 code constraint. 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 on parcels.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 the DATASET label — 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 /search is 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 + pinnum matching — 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, and parcels.id is 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 resets admin@example.com’s password whenever the stored hash is invalid or simply doesn’t match password123 — 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 stale https://zones-api.dev-stage.fyi/api base URL when VITE_API_URL is unset — audit whether that dev-stage host is still alive.

09 · Transformation

The plan — ship it, internally recommended

After today’s two calls (Rowan internal, then with Scott, Steve & Barry), the direction is decided. Rowan builds and ships this internally rather than briefing an outside developer — the team already understands 60–70%+ of the tool, the “mockup” is really ~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, costs the client nothing extra, and produces the clean documentation for a clean future hand-off (to Mohsen or a dedicated dev) once it’s live and proven. Target: V1 in ~1 month, aligned to the new-website launch (~Sept 7).

Ship the front-end first

Barry’s priority is a working sales tool on the new website — not a perfect data pipeline. So we publish at ~60–70% coverage / ~90% data accuracy, then iterate (“get it out, see how people use it”). The data-automation work (Tier 2) comes after.

Tier 1 · now

Finalize the front-end

Make the existing tool an accurate sales funnel on the new site — the deliverable Barry cares about most (~1–2 weeks).

What ships

  • Address intelligence — fuzzy address match, look-up by PIN (Barry’s #1), and by owner name, with an early “did you mean…?” step
  • House-plan matching — compare the max ADU size against the full CC plan catalog and show every fitting model (not two placeholders); “up to X sq ft” filters
  • Accurate data display + the “assuming city water & sewer” caveats
  • Launch markets: Asheville, Buncombe, Durham
Tier 2 · later

MCP / API — Scott owns the data

Give Claude API access to the backend so Scott & Steve maintain the data themselves, no developer in the loop (~2–3 weeks, after Tier 1).

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 — we augment Scott, we don’t replace him
  • Replaces the old hand-entered backend formulas (the dead-end that would have cost “days of programming”)
Keep the fast database — the CSV feeds it, it isn’t the lookup

Barry’s instinct was “just let it read from the CSV.” We honor that without slowing the tool down: Scott’s CSV is the human-validated 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.

Growth: give it to realtors

One backend powers three surfaces: the CC ADU page, a standalone tool, and an embeddable widget for realtors’ and land-listing sites — a prospect on a partner’s site looks up a lot and gets referred to Compact Cottages. Rowan proposal, not yet client-confirmed: a ~5% referral commission on confirmed sales.

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 for the Monday decision.

The two paths through the target system

Read path (public, fast): surface (ADU page / realtor widget / chatbot / zones-site successor) → GET /verdict?address|pin|id → the API resolves the parcel (Postgres, with pg_trgm typeahead served server-side — retiring the 3.5 MB client index), resolves the district via the alias map, loads that zone’s standards + uses, evaluates server-side, and returns one JSON verdict (state, allowed uses, dimensional numbers, ADU cap, fitted CC models, geometry ref). Numbers are never computed client-side again.

Author path: the editable rules store (in the existing backend, reachable over API / MCP) → validate + version → versioned upsert into the Postgres read model.

The target data model — what fixes N×M

jurisdictions

id, county, name, kind (county/city/town), state. 18 rows for the current footprint.

zones

id, jurisdiction_id, code (e.g. “RM8”), display name, UDO citation/link, status (draft/validated/live), effective_from/to. Key = (jurisdiction, code) — kills the global-unique-string hack and the RS2-vs-R1 collision by construction. ⚠ Migration note: today’s DB enforces a globally-unique zoning_rules.code (migration 1767891498570), so moving to (jurisdiction, code) means dropping a live uniqueness constraint and splitting concatenated codes (“RM8 Asheville” → jurisdiction=Asheville, code=RM8) — plan the data migration explicitly.

zone_aliases

Raw parcel string → zone_id (e.g. "RM8 Asheville", "RM8  Asheville", "R-2 Buncombe County"). Ingest-time rule: every active parcel’s zoning_district_location MUST resolve to an alias or be flagged in a coverage report — no more silent no-match.

dimensional_standards

One row per zone — wide, typed, unit-suffixed columns mirroring the Translator’s Dimensional sheet (min_lot_sf with no-sewer/sewer/sewer-water variants, min_lot_width_ft, flag-lot widths incl. 7F, front/side/rear setbacks with sewer variants, ADU side/rear setbacks, max_height_ft, density_du_ac). NULL = not applicable; never free text.

overlay_modifiers

zone_id (or jurisdiction_id) + overlay key (7F, steep_slope, pedestrian_area, urban_tier, conservation_district) + the column it overrides + value. This is where “35 (20 steep slope)” becomes data.

allowed_uses · uses

zone_id × use_id → enum {NO=0, YES=1, MAYBE=2}. uses = the 23-product taxonomy (global, versioned, with product links).

computed_rules

The small set of genuine formulas, parameterized not hard-coded: e.g. ADU cap = agg(pct × principal_HLA, cap_sf) where pct/cap/agg(min|max) are columns per jurisdiction — covers Asheville 70%/800/min, Woodfin 70%/800/max, Mills River 60%/1000/min, Fletcher’s acreage tiers as rows, and Orange County’s “50% or 1,500 SF, whichever greater” with agg=max. Evaluated by ONE unit-aware server engine with tests; results cached on the response. Presentation rounding (the ADU tool’s round-down-to-10) stays in the surface layer, explicitly documented per surface — the API returns the exact value.

rule_versions / audit log

Every sync = a version; effective-dated; decoupled from parcel dataset versions.

parcels keep

Keep the existing table + JSONB metafield pattern (it works). Add: numeric typing for acreage/parcel_sf/HLA; unique (dataset_id, pin, bldg_no); true replace semantics (delete the dead, never-fires “duplicate pins skipped” warning path); and an activation hook that (a) runs the alias-coverage report and (b) regenerates the address index/label automatically.

The onboarding workflow — adding a county

  1. Scott builds the data in ArcGIS + Excel: pull the county tax-office parcel file, join zoning onto every parcel (tabular by PIN, or spatial by polygon where there’s no PIN), add the standardized fields, and prepare the dimensional standards.
  2. Scott exports a clean, validated CSV per jurisdiction — using liberal assumptions (e.g. city water & sewer) for a ~90% target, with the caveats noted.
  3. Claude drafts the hard parts — AI can pre-fill ~90% of the uses/standards from the county’s UDO for Scott to proofread, instead of typing every cell by hand.
  4. Scott validates against the UDO and signs off — the human-in-the-loop gate.
  5. Claude / the API pushes the CSV into the database (the right calculations, uses and datasets) — no manual re-entry.
  6. A quick coverage check confirms every active parcel’s zoning resolves before the jurisdiction goes public.

Test-first: Scott prepares a small, simplest-possible dataset (one jurisdiction, a handful of rows) so we can trace every value end-to-end — from the CSV, through the rules and calculations, to what the front-end shows — before loading thousands of parcels.

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.

Today · manual Scott drives every step by hand, per county, every refresh
Stage 1 · ArcGIS Pro
Scott: spatial join
polygons/tables onto parcels; hand-minted special districts
Stage 2 · Excel
rename / concat / derive
builds the exact-string join key
eliminated
Stage 3 · CSV
save-as CSV
Buncombe: 105 cols, 134,868 rows
Admin
upload dataset
zones-admin, one file
Admin
Activate
promotes to live

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.

…and every refresh, two more chores by hand
Static hack
Hand-regenerate 3.5 MB address index
buncombe-addresses.json baked into the page; goes stale silently
eliminated
Rules layer
Re-type rules into admin
Translator → matrix cells + Formula Builder, one keystroke per cell
eliminated
Target · pull & validate Parcels ingest themselves; rules are AI-drafted, human-validated, refreshed ~2×/year
Parcels · source
Regrid / county pull
replaces Stage 1 + most of Stage 2, any US county
Ingest
Normalized ingest
null the -5555 / -9999 sentinels; typed columns
auto
Activation hook
Coverage report + index regen
every parcel resolves via alias map or gets flagged
auto
Rules track — AI-draft then human-validate
Source of truth
UDO
the ordinance text
AI
AI drafts rows
23-use mapping + dimensional standards, per-cell UDO citations → status = draft
Human gate
Scott validates
against the UDO; flips county to validated
Sync
Click Refresh
n8n validation report → versioned upsert
Live
Live verdict
server-side; rules change ~2×/year

One human interface point — a Refresh click. Rules authoring is never in the hot path; the public API reads only the typed Postgres model.

Diagram (e): the parcel + rules pipeline, today vs. target. Today, each refresh is hand-driven — an ArcGIS→Excel→CSV→upload→Activate chain whose join key is an Excel concatenation, plus a hand-regenerated 3.5 MB address index and rules re-typed into admin (all three, red, are removed in the target). The target pulls parcels from Regrid/county, ingests and reports coverage automatically, and moves rules onto an AI-draft → Scott-validates → Refresh → live path (~2×/year).

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-copy AI 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.fyi default base URL

10 · 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.

AcceptanceNo unauthenticated PII; walking IDs fails; the admin password survives a restart; pen-check of the security findings in Limits.

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.

AcceptanceAn address, a PIN, and an owner-name all resolve; a lot shows every CC plan that fits under its ADU cap; caveats display; live on the new website by ~Sept 7.

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.

AcceptanceScott adds/updates a county from Excel → CSV → Claude/API → DB with no dev in the loop; the test dataset traces cleanly from CSV to front-end; a rule change (e.g. ADU min → 1,200 sq ft) is a CSV edit, not code.

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).

Anchor test parcels — the ground truth for every parity test

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 · API zones-api-94c4p.ondigitalocean.app (Swagger /api, /api-json) · admin zones-admin-ok6tv.ondigitalocean.app · CC staging site compact-cottages-website.mitch-bb3.workers.dev · demos: cc-adu-tool-preview.pages.dev, compact-cottages-lot-widget.pages.dev, board cc-zoning-briefing.pages.dev.
  • Repos / code: github.com/Barry-Bialik-Projects (zones-api / zones-admin / zones-site); local read-only clone external-code/compact-cottages/zoning-tool/; website + ADU tool github.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).

11 · 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

  1. Who builds it: Rowan, internally — no external dev.
  2. 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.
  3. Accuracy target: ~90–92% is fine for launch, using liberal assumptions (e.g. city water & sewer) with an on-screen caveat.
  4. 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.
  5. Building size / heated area: appended from public real-estate files (not native to parcel data).
  6. Lot dimensions: pulled live from ArcGIS geometry, not the CSV.
  7. Rules entry: stop hand-entering backend formulas (the “days of programming” dead-end); fill from Scott’s CSV via the API instead.
  8. Priority: front-end first — address / PIN / owner-name lookup is Barry’s #1.
  9. Launch markets: Asheville, Buncombe, Durham.
  10. Data source: county tax-office parcel files + ArcGIS — not Regrid.

Still open Monday +

To confirm on the Monday call & as we build

  1. Client go-ahead on the plan (internal build; Tier 1 front-end, then Tier 2 data) — the Monday ask.
  2. 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.
  3. House-plan matching logic — which CC plans, and the “up to X sq ft” caps (Durham flag lots cap ~1,200).
  4. Split-zoned parcels (one parcel, two districts) and field-heading growth across 35+ municipalities — keep it manageable.
  5. Secure the API before public launch (it’s open today) — agree the timing.
  6. Feasibility-study pricing for the CTA — confirm current options before hard-coding.
  7. Realtor referral model (~5% on confirmed sales) — Rowan proposal, needs client sign-off.

12 · Sources & confidence

How this board was produced, and how much to trust each fact

The current-state facts are synthesized from five prior analyses plus two Aug-19 primary-source captures — a live admin-UI walkthrough and live API JSON — then corrected in an adversarial verification pass. The plan & decisions (§9–11) come from two Aug-19 strategy calls: a Rowan-internal session (Mitch + Amine) and the client call with Scott, Steve & Barry. Source precedence: live captures > primary source code (read-only clones) > transcripts (intent/vision) > older derived analyses. Where sources conflicted, the conflict was named and resolved, not averaged; call transcripts are unlabeled, so who-said-what is inferred from context.

High confidence — live API JSON or code, independently re-verified

The single GET /properties/{id} endpoint shared by both front-ends · Buncombe = dataset 8 with 15 calc fields · the 23-column matrix and its exact-string join · the stored ADU formula min(0.70×HLA, 800) with presentation-only round-to-10 · no server-side evaluator · no dedup + the dead duplicate-warning path · the seedAdminUser password-revert behavior · the globally-unique code constraint · zones-site’s stale base-URL default · both anchor parcels (178 Fairfax Ave id 408223 → cap 532 exact / 530 displayed; 1717 Old Haywood Rd id 403372 → cap 0, vacant).

Observed snapshot — one-time admin-UI walkthrough, 2026-08-19

Parcel counts 134,420 / 132,268 / 71,124 (≈337,812 total) · Durham 1+1 and Henderson placeholder-only calc fields · the archived Jul-20 Buncombe upload. Not reproducible from source — re-read from the admin before quoting as current.

Inference — speaker attributions

All named speaker attributions on transcript quotes: the transcripts are unlabeled turn-by-turn dialogue; quotes are verbatim, speakers are inferred from context.

Corrections applied in this final version (condensed)

  • Removed a false “two property endpoints” discrepancy — there is only GET /properties/{id}; the “/search/property/{id}” claim was a mislabel of zones-site’s frontend route.
  • ADU round-to-10 restated as universal presentation rounding, not fallback-only; ADU tool file size corrected to 1,689 lines.
  • Orange County made precise: 3 municipalities blank; unincorporated county has partial drafts.
  • “Mockups approved” softened to “positively received, no formal sign-off”; 30-day attribution re-cited to the 2026-08-03 note; “agent cc’d” marked mockup-only; $650 re-cited to the internal transcripts that actually contain it and flagged as in-flux.
  • Seed-admin failure mode strengthened (it reverts changed passwords); the dead duplicate-warning code called out.
  • Counts labeled as 2026-08-19 snapshots, with the 134,868-rows-vs-134,420-parcels CSV gap noted; “2 of ~120 districts” made precise (2 district-keyed + jurisdiction flag-lot widths + the Asheville-wide ADU cap).
  • The globally-unique code migration named as a constraint the rebuild must drop; Mohsen/Mohsin harmonized; speaker attributions labeled as inferred.