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
- 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.
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
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.
“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
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 AI-fill workflow — onboarding a new county
- Pull the Regrid zoning table (null the
-5555/-9999sentinels) + fetch the county’s UDO. - AI drafts
zones,allowed_uses(the 23-use mapping),dimensional_standards+ overlay modifiers — with per-cell citations to UDO sections. - Rows land as status=draft in the rules store.
- Scott validates against the UDO (per-county checklist) and flips the county to validated.
- Refresh syncs.
- Parcels uploaded or Regrid-pulled.
- 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.
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
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.
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.
/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).
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.
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) · 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
- zones-site config: its API client’s baked-in default base URL is
zones-api.dev-stage.fyi— what does the DO app’sVITE_API_URLactually point at, and is the dev-stage host still alive / decommissionable? (No endpoint ambiguity exists — both front-ends call the sameGET /properties/{id}.) - 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?
- Why is the Jul-20 Buncombe refresh archived / un-promoted — process gap or intentional? Intended refresh cadence, and who regenerates the address index?
- 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).
- 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.
- Heated-living-area provenance per county (Buncombe finished-area table vs Durham HEATED_ARE) — which columns feed the ADU math today?
- The
sevenfknown errors and the Flat Rock CITY patch — is upstream correction planned, or does the rebuild own these fixes? - 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)?
- Orange roadmap: is finishing Carrboro / Chapel Hill / Hillsborough on Scott’s roadmap, and would he accept AI-drafted fills to validate?
- Regrid: does CC have/want a license, which tier, and does Scott endorse it as substrate + cross-check?
- PII: confirm CC knows the public API exposes owner data + owner-name search today, and agree the Phase-0 gating plan.
- 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
- 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
- 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?
- Budget / timeline envelope for the freelancer ask; bless the 4-phase scope for the 2–3 shortlisted devs.
- 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.
- 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.
- County order after Buncombe parity: Durham (market-entry story), then Henderson, then Orange?
- Accept retiring the public zones-site (owner search) in favor of the new surfaces + an internal QA view?
- 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
codemigration named as a constraint the rebuild must drop; Mohsen/Mohsin harmonized; speaker attributions labeled as inferred.