Zoning Tool — Current State
& Rebuild Brief

What the “Zoning Search” system actually is today, the two directions we’re weighing for it, 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.

Rowan’s direction (early — being validated with the client): keep the fast DigitalOcean lookup and the existing backend rather than rebuild it, and make the manual rules layer scalable with AI — AI pre-fills each county’s rules from its UDO, a human (Scott) validates, and the approved rules flow into the existing backend, which we expose over an API/MCP so the whole tool is programmatically usable. Two consumer surfaces run off the one backend: the CC ADU lead magnet and a free realtor widget. This board is the corrected current-state picture and the two directions we’re weighing — the finalized scope goes to a new senior developer once the open questions are answered.

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 two public front-ends: the internal ADU tool on the Cloudflare marketing site (a 1,689-line hand-written page, the real consumer funnel) and the zones-site public lookup. Both 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 — two 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} both public front-ends → one 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 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
Two front-ends, one open door. The internal ADU tool and zones-site both 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 two public front-ends — the internal ADU tool (Cloudflare marketing site, the real consumer funnel) and the zones-site lookup — which both 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.

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

Two directions we’re weighing early / draft

Both directions share the same non-negotiables: keep the existing DigitalOcean backend and the fast lookup (don’t rebuild the app), fix the Phase-0 security issues, make the manual rules layer scalable with AI + human validation, and expose the whole tool over an API / MCP so it’s programmatically usable. This is a raw draft — the finalized recommendation goes out after the open questions are answered (targeting next Monday).

A · Rowan takes it on internally

Rowan enhances the existing backend directly — via the Claude API / MCP and working sessions with Scott — turning the manual pieces into AI-assisted, scalable ones while keeping the same logic and the same backend. Add APIs so the rules are editable/queryable programmatically, add AI search capability, and keep DO for speed.

Pros

  • No rebuild — fastest path to value; keeps the fast DO lookup
  • We own it end-to-end, in a tight loop with Scott
  • Every manual step becomes an AI-assisted one

Cons

  • Rowan carries delivery + ongoing improvements
  • Needs disciplined SOPs so AI-generated rules are always human-validated

Who maintains

Rowan (backend enhancements + AI layer); Scott validates the rules.

B · A vibe-coded onboarding plugin

A separate, vibe-coded extension that automates county onboarding: AI researches the county’s UDO, pre-fills the translator (the 0/1/2 uses + dimensional standards), a human (Scott) validates row-by-row, and the approved rules push automatically into the existing backend. Removes ~80% of the manual work without rebuilding the app.

Pros

  • Attacks the real bottleneck — manual per-county rule authoring
  • Bolts onto the existing backend; no core rewrite
  • Scales to new counties/states with AI + a validation step

Cons

  • A new tool to build and maintain
  • Only as good as the human validation loop (SOP required)

Who maintains

A senior vibe-coder builds it; Scott runs validation; rules land in the existing backend.

These two aren’t mutually exclusive — A (own + enhance the backend) is the foundation; B (the onboarding plugin) is the scale lever on top of it. The common thread: the rules stay in the existing backend, scaled by AI pre-fill + human validation and exposed via API/MCP — no third-party rules database.

Keep the backend — scale it with AI, not a rebuild

“There’s enough of the upload tools in the back end that’s functional — what’s missing is the lookup function.” So we keep the fast DigitalOcean lookup and the existing rules store, make it editable/queryable over an API/MCP, and let AI pre-fill each new county’s rules for a human to validate before they go live. That’s the path to scale without rebuilding the app.

A detailed target-architecture diagram is intentionally left out of this draft — the finalized architecture is being locked after the open questions are answered. The shape below (data model, pipeline, keep/rebuild/retire) is the technical direction we’re exploring, not a committed design.

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 AI-fill workflow — onboarding a new county

  1. Pull the Regrid zoning table (null the -5555/-9999 sentinels) + fetch the county’s UDO.
  2. AI drafts zones, allowed_uses (the 23-use mapping), dimensional_standards + overlay modifiers — with per-cell citations to UDO sections.
  3. Rows land as status=draft in the rules store.
  4. Scott validates against the UDO (per-county checklist) and flips the county to validated.
  5. Refresh syncs.
  6. Parcels uploaded or Regrid-pulled.
  7. The coverage report must show ~100% alias resolution before the county goes public.

For Orange, the proving case starts from real partial work: unincorporated Orange County already has draft Permitted-Uses + dimensional sheets in the Translator; the three municipalities (Carrboro / Chapel Hill / Hillsborough) are the genuinely blank cells for AI to fill.

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

Four phases, ship-ready

Mission: transform a working parcel-lookup system with a hand-typed, Buncombe-only rules layer into a rules-as-data platform serving verdicts for 4+ counties, editable by non-developers, with AI-assisted county onboarding. Reuse the DO/Postgres backbone; do not greenfield the ingest.

Phase 0 · Stabilize & secure 1–2 weeks

Gate /search + /properties (or strip PII from public payloads + non-enumerable IDs); remove the seedAdminUser password-reset behavior (it reverts any changed admin password to password123 on every restart) + the JWT fallback secret; add RolesGuard, rate limiting, CORS scoping, DB TLS verification; fix zones-site’s stale default base URL; decide + promote (or discard) the Jul-20 Buncombe refresh.

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

Phase 1 · Rules platform + server verdict 3–5 weeks

Implement the target schema (including the migration off the globally-unique code constraint); migrate the matrix, the 15 formulas, and the Translator’s Buncombe/Asheville dimensional sheets into it; build the alias map for all active-parcel strings; one server-side evaluation engine; new /verdict endpoint; an editable rules store synced into the read model with a validation report.

Acceptance — parity tests178 Fairfax Ave (id 408223, RM8 Asheville, HLA 760): /verdict returns ADU cap 532 exactly (= min(0.70×760, 800)); the ADU page displays 530 — its presentation-layer round-down-to-10, expected, not a bug. 1717 Old Haywood Rd (id 403372, RM8, vacant): cap 0 with vacant-lot state. 100% of active Buncombe parcels resolve via the alias map or appear on the coverage report; verdict p95 < 500 ms; a rules edit → Refresh → live answer change round-trips with the version recorded.

Phase 2 · County completion 2–4 weeks

Load Durham + Henderson standards from the Translator (they’re authored — just never re-keyed); build the AI-fill pipeline and run it on Orange as the proving case — unincorporated Orange County has draft rules in the Translator; the 3 municipalities are blank (AI draft → Scott validates → live).

AcceptanceA Durham and a Henderson address return full verdicts; Orange onboarded end-to-end via the AI workflow with Scott’s sign-off; per-county onboarding runbook ≤ 1 day of human time.

Phase 3 · Surfaces 2–4 weeks · order pends the Thursday decision

Point the ADU tool at /verdict (delete its client evaluator + static index; server typeahead); build the realtor widget from BRIEF-B (attribution model + agent-cc pending Thursday confirmation); wire lead capture to the CRM / webhook (today a no-op stub); expose a documented chatbot-safe API.

AcceptanceADU tool passes parity on both anchor parcels with live (non-cached) data; widget demo on the simulated listing page; leads land in the CRM; the chatbot answers “can I build an ADU at <address>?” via the API.
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) · Regrid samples (NC_*_zoning.csv — mind -5555/-9999; Regrid_CSV_nc_*.csv; STANDARD/PREMIUM DBF samples) · 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 · Open questions

What we still need to decide — this week

Wednesday · Scott

Technical & data — Aug 19

  1. zones-site config: its API client’s baked-in default base URL is zones-api.dev-stage.fyi — what does the DO app’s VITE_API_URL actually point at, and is the dev-stage host still alive / decommissionable? (No endpoint ambiguity exists — both front-ends call the same GET /properties/{id}.)
  2. Is the “Upload Zoning Rules CSV” path wired at all post-refactor, or stale help text as the migration evidence suggests? Was the matrix populated by hand or import?
  3. Why is the Jul-20 Buncombe refresh archived / un-promoted — process gap or intentional? Intended refresh cadence, and who regenerates the address index?
  4. Replace/Enlarge + dedup: confirm live behavior of a re-upload (code says: always insert, no dedup, mode ignored; the “duplicate pins skipped” warning is dead code that can never fire).
  5. Split-zoned Durham parcels and multi-building parcels (BldgNo/FREQUENCY rows): how should the verdict handle them? Confirm the 134,868-row vs 134,420-parcel semantics.
  6. Heated-living-area provenance per county (Buncombe finished-area table vs Durham HEATED_ARE) — which columns feed the ADU math today?
  7. The sevenf known errors and the Flat Rock CITY patch — is upstream correction planned, or does the rebuild own these fixes?
  8. Rounding: the stored ADU formula has no rounding; the website rounds every displayed cap down to the nearest 10. Intended product behavior (keep it in the new surface) or an accident (drop it)?
  9. Orange roadmap: is finishing Carrboro / Chapel Hill / Hillsborough on Scott’s roadmap, and would he accept AI-drafted fills to validate?
  10. Regrid: does CC have/want a license, which tier, and does Scott endorse it as substrate + cross-check?
  11. PII: confirm CC knows the public API exposes owner data + owner-name search today, and agree the Phase-0 gating plan.
  12. One-table skepticism: Scott was expected to resist “one table” — walk the target schema (per-county columns + overlays) and get his objections concrete.

Thursday · Barry / Steve

Direction & product — Aug 20

  1. Priority user / first surface: CC ADU upsell vs standalone realtor tool — which ships first, and which capabilities matter most? (Barry leans standalone-for-selling but wants a CC page regardless.) headline
  2. Confirm the direction — keep and enhance the existing DigitalOcean backend (no third-party rules database), scale the rules with AI pre-fill + human validation, and expose the tool over an API / MCP. Which of the two directions (internal build vs. onboarding plugin), and in what order?
  3. Budget / timeline envelope for the freelancer ask; bless the 4-phase scope for the 2–3 shortlisted devs.
  4. Realtor attribution model: 30-day click attribution was presented with the mockups; “agent cc’d on leads” is mockup-only — confirm both as product decisions, not just mockup spec.
  5. Feasibility-study pricing: $650 appears in internal transcripts, but “there’s basically three different ways to buy a study now” — confirm current price/options before anything is hard-coded in a CTA.
  6. County order after Buncombe parity: Durham (market-entry story), then Henderson, then Orange?
  7. Accept retiring the public zones-site (owner search) in favor of the new surfaces + an internal QA view?
  8. Mockup sign-off: the two mockups were positively received (“a strong starting point”) but never formally approved — get explicit direction before the dev builds surfaces from them.

12 · Sources & confidence

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

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

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.