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