pre_production before building; this spec was written from a read-only pass on 2026-07-02.
1. Context and strategy
The V3 prototype (artifacts/wrea/valuation-prototypes/wrea-property-estimate-tool-v3-2026-06-30/) is a WREA-branded React/Vite app behind a small Node server: landing → address → 5s loading → results (range, comparables, agent questions) → contact gate. The product shape is good and converts the right way: the estimate is positioned as a benchmark, and the CTA reframes the user's question from "what's the number?" to "which agent can explain and defend the number?" — a wedge LocalAgentFinder and OpenAgent don't own.
Review verdict (delivered to Codex 2026-07-02): the product ships; the prototype's plumbing does not. The raw GET /api/property-estimate?address=… endpoint is scrapeable, every lookup makes two live upstream calls with zero caching, and the portal's existing patterns — if copied — would reproduce the problem (§5.2). This spec is the production path: same product, hardened rails.
Strategic role (from the production review brief): acquisition landing page for paid/organic/competitor-intent traffic; lead qualification; sales enablement; email-capture engine for the nurture operating system; evergreen standalone asset. The keyword opportunity is real: "property value" cluster ≈ 80k+ monthly searches where WREA is absent or weak; OpenAgent's OpenEstimates (118k vol) runs exactly this loop.
2. Data rights and sourcing posture owner-verified gates
The tool republishes two data classes to anonymous consumers, which is a different rights posture from internal CRM use. Ownership of the two gates is recorded here:
| Gate | Position | Owner |
|---|---|---|
DR1 — Sold-listings comparables (scraped listings table: street address, price, month/year, beds/baths/cars) | Thomas will separately verify the listings data is in the public domain before it is used in the tool. Until that verification is recorded, comparables ship in the beta behind a config flag (estimate.comparables_enabled) so the tool can launch with or without them. Fallback if verification narrows scope: suppress street numbers, or gate full addresses behind lead capture. | Thomas |
DR2 — Estimate source (ODIN) (the private market data lane in the prototype: MARKET_DATA_HOST/PATH, server.mjs:53-67) | ODIN is a paid subscription — Thomas pays for this data separately. It is a licensed source, not an unlicensed scrape. Engineering treats it as licensed; the only residual action (with Thomas) is a one-time check that the subscription tier permits consumer-facing display of derived estimates, recorded in this doc when confirmed. | Thomas |
Independent of rights, the rounding + stable-adjustment layer (§7.4) stays: it prevents the public payload from echoing raw source values exactly, which is good hygiene for any licensed source.
3. Executive decisions taken
Opinionated calls baked into this spec. Veto before build starts — everything downstream depends on them.
| Decision | Rationale |
|---|---|
| D1. Keep the React app; mount it as a Vite island in the portal. | Rewriting in Blade/jQuery wastes the prototype and slows shipping. Precedent exists: the flow-editor is already a Vite+Vue island (vite.flow-editor.config.js). A Blade wrapper route provides session, CSRF, GTM and brand chrome. |
| D2. Three POST endpoints with signed single-use candidate tokens; no raw-address GET. | The prototype endpoint is a free oracle. Tokens are HMAC-signed, TTL 10 min, session-bound, single-use; provider identifiers never reach the client (§7.2). |
| D3. Cache-first with a global upstream budget and circuit breaker. | Two live ODIN calls per lookup with no cache is a cost and availability hole even with a paid subscription. Redis cache by normalised-address hash (14d TTL); daily call budget (~500/day to start) degrading to the existing suburb-tier fallback (PropertyValuationService). |
| D4. Value-first, gate the shortlist. | Full range + 3 comparables + narrative free; agent shortlist, full comparable set, and emailed report behind contact details; SMS PIN verification before lead handoff to sales/agents, not before the shortlist teaser. Teaser-gating the estimate reads as bait and hurts paid conversion. |
| D5. Deterministic estimates keyed on the resolved property, not the typed string. | The prototype's adjustment factor hashes the raw user input (server.mjs:543,551) — "18 Tooke St" vs "18 Tooke Street" returns different numbers for the same house. Trust-destroying and screenshot-comparable. Key on the resolved provider record id (server-side only). |
| D6. Turnstile, risk-triggered, never on the first lookup. | Portal reCAPTCHA enforcement is commented out repo-wide and only flags score ≤0.1 — don't rebuild on it. Cloudflare Turnstile challenges from the 3rd distinct address per session; first-lookup users (paid traffic) are never challenged. |
| D7. All rate-limit and estimate caches explicitly on Redis. | Portal default cache driver is database; under burst (exactly when limits matter) DB-backed counters fall over. Cache::store('redis') everywhere in this feature. |
| D8. Nurture integration via the existing nurture spec's contract — no separate drip. | The tool emits pvt_events, scoped consent, verified email, and estimate history exactly as specced in reports/wrea-email-nurture-build-spec-2026-07-02.html §12. This tool is that system's email-capture engine. |
4. Requirements
4.1 Functional
| ID | Requirement | Phase |
|---|---|---|
| R1 | Public flow: address autocomplete → candidate selection → estimate (range + confidence label + narrative) → comparables (flag-gated per DR1) → agent-question section → shortlist CTA → contact gate → SMS verification → CRM lead. | P1 |
| R2 | No raw-address value endpoint; POST session/token design per §7.2. Provider IDs, source names, raw estimates never leave the server. | P0 |
| R3 | Cache-first lookups: repeat addresses served from Redis without upstream calls; global daily upstream budget with circuit breaker and Telegram alert. | P0 |
| R4 | Same resolved property ⇒ same estimate, regardless of address formatting (D5). | P0 |
| R5 | Comparables use real geographic adjacency (lat/lng radius or suburb adjacency), replacing the prototype's 4-suburb curated map + postcode ±12 fallback. | P1 |
| R6 | Lead creation reuses the capsule→CRM path with a compact valuation summary (range, top-3 comparables, suburb context, agent questions) attached to the CRM lead note. | P1 |
| R7 | Emailed estimate report with magic-link email verification ("save your estimate"), feeding nurture consent scopes. | P2 |
| R8 | Full funnel event tracking per §10, banded dimensions only. | P1 |
| R9 | Graceful no-data / thin-market UX verified in ≥5 non-Newcastle markets before public launch. | P2 |
| R10 | Agent-evidence teaser, three-scenario pricing strip, and suburb fee context on the results page (the "uniquely WREA" layer). | P3 |
| R11 | pvt_events + property_estimates emission per the nurture spec contract (§17). | P2 |
4.2 Non-functional
- N1 — Fail useful. Upstream failure or budget exhaustion degrades to the suburb-tier estimate (existing
PropertyValuationServicetiers) with honest lower-confidence copy — never a dead end on paid traffic. - N2 — Response whitelist enforced by a transformer, not ad-hoc omission; a release-gate grep proves no provider terms in built assets or responses.
- N3 — Never challenge or block the first lookup of a session; abuse controls escalate with behaviour (§9).
- N4 — p95 ≤ 2.5s server time on cache miss (both upstream calls in parallel where possible), ≤400ms on cache hit; the UI loading step adapts to real latency (§8.3).
- N5 — Every prototype-only artefact is unshippable by construction — test contact defaults, wrong phone, "prototype" strings are removed in the port, with a checklist test (§13.4).
- N6 — Privacy: APP collection notice at the contact step, scoped marketing consent, addresses HMAC-hashed in application logs.
5. Current state: prototype findings and portal gaps (code-verified 2026-07-02)
5.1 Prototype findings (fix in the port)
| # | Finding | Evidence |
|---|---|---|
| F1 | Raw GET value endpoint, no limits, no session | server.mjs:685-698 — /api/property-estimate?address=… |
| F2 | Two live upstream (ODIN) calls per request, zero caching | server.mjs:662-672 (lookupValue: locations + property detail) |
| F3 | Estimate varies with address typing (non-deterministic per property) | server.mjs:543,551 — stableFactor(`${address}:mid`) keyed on raw input |
| F4 | Header phone is 133 033 — not WREA's number (portal uses 1300 665 557 in 32 views); competitor-adjacent leftover | src/main.jsx:52-54 |
| F5 | Prefilled test contact (wrea-review@example.invalid) | src/main.jsx:398-403 |
| F6 | Unsubstantiated ★★★★★ trust row — ACL fake-rating exposure and LAF-style furniture | src/main.jsx:76 |
| F7 | Hero "Know your likely sale price" contradicts the estimate positioning; "Estimate benchmark from WREA market data" misattributes a licensed third-party source | src/main.jsx:68,219 |
| F8 | Comparables geography: curated map covers 4 Newcastle suburbs; fallback postcode ±12 is geographically wrong in most of Australia | server.mjs:222-228, 393-394 |
| F9 | Comparables query shells out to the mysql CLI with hand-escaped string SQL and MYSQL_PWD in env — prototype-only pattern | server.mjs:416-429 (escaping via sqlString, :183) |
| F10 | Internal wording leaks to UI ("local read-only data lane"); "prototype" strings in copy | server.mjs:496; src/main.jsx:349-351,365 |
| F11 | Fixed 5s artificial loading step | src/main.jsx:6,424-426 |
| F12 | Clean: built bundle contains no provider/source terms (scan hit only "WREA"/"Real Estate Agent" substrings); public payload has no provider IDs or raw values | dist scan 2026-07-02; server.mjs:622-641 (publicPayload) |
5.2 Portal gaps (the patterns NOT to copy, and what to add)
| # | Gap | Evidence |
|---|---|---|
| G1 | Closest existing analog GET /api/v1/property-value/{id}/{campaignId?} is live with no throttle, no auth, no captcha, no referer check | routes/web.php:334 (bare group at :268) → ApiController@calculatePropertyValue |
| G2 | reCAPTCHA verification exists but enforcement is commented out repo-wide; threshold only score ≤0.1 | HomeController.php:36-59 (~:334), ApiController.php (~:170) |
| G3 | Main lead FormRequest has empty validation rules (client-side only) | app/Http/Requests/ApiRequest.php — rules() returns [] |
| G4 | CORS is Access-Control-Allow-Origin: * on the whole api group — not a scraping control | app/Http/Middleware/CorsMiddleware.php |
| G5 | Default cache driver is database; throttle counters share it (falls over under burst) | .env.example — Redis configured but not default |
| G6 | track/* endpoints have no throttle | routes/api.php:65-75 |
| G7 | Reusable and good: SMS PIN with attempt lockout, Experian + blacklist, capsule→CRM flow, Places autocomplete, Cache::remember pattern, PropertyValuationService suburb tiers, GTM conventions | §19 reference table |
6. Target architecture
POST /api/v2/estimate/search · POST /api/v2/estimate/value · POST /api/v2/estimate/lead (api group: throttle + CORS; plus per-route throttles and session binding)
search; value takes tokens only.PropertyEstimateService (ODIN wrapper + rounding/adjustment) · ComparableSalesService (remote_mysql, parameterised, DR1 flag) · EstimateSessionService (tokens/limits) · EstimateBudget (daily cap + breaker)
PropertyValuationService suburb/type/bedroom tiers → state defaults, with lower-confidence copy (N1)pvt_events + property_estimates emission (§17) · GTM + server-side track events (§10)7. Backend implementation detail
Laravel 8.x / PHP 8.2, branch pre_production. New namespace App\Services\Estimate\. FormRequests with real rules — do not extend or copy ApiRequest (G3).
7.1 Endpoints
POST /api/v2/estimate/search throttle:10,1 + session
in : { query: string (5..120 chars) }
do : validate; call ODIN locations lookup (cache 10m by normalised query);
score candidates (port server.mjs:87-138); store top 5 candidate records
server-side in Redis (10m TTL); mint one HMAC token per candidate
out: { candidates: [ { displayAddress, token } ] } // nothing else
POST /api/v2/estimate/value throttle:5,1 + session + abuse layer (§9)
in : { token }
do : verify HMAC + TTL + session binding + single-use tombstone;
load candidate record (server-side) → resolved provider record id;
estimate = Redis cache by sha1(normalised_address) ??
(budget.consume() ? ODIN property detail : suburb fallback tier);
apply rounding + stable adjustment keyed on provider record id (D5/R4);
comparables = config('estimate.comparables_enabled')
? ComparableSalesService::for(property) : null; // DR1
out: transformer whitelist (§11.1) only
POST /api/v2/estimate/lead throttle:10,1 + session
in : { estimateSessionId, firstName, lastName, email, phone, consent{} }
do : real validation rules; Experian + blacklist (existing); capsule→CRM
with valuation summary note; SMS PIN via Api\LeadController flow;
emit pvt_events (value_requested/viewed already logged; lead events here)
out: { leadId, pinRequired: true }
7.2 Candidate token design
- Payload:
{cid, sid, iat, exp}wherecidis a random key referencing the Redis-stored candidate record (which holds the provider id, formatted address, suburb/state/postcode — server-side only),sid= Laravel session id hash. - HMAC-SHA256 with
APP_KEY-derived key; verify signature → TTL → session binding → tombstone (RedisSETNX estimate:used:{cid}, TTL = token TTL). Replays return a generic error and increment the abuse counter. - Client never holds an address→value oracle: value calls cost a fresh
searchround-trip per address, which is where limits and Turnstile live.
7.3 Comparables (port + fix of server.mjs:262-501)
- Query via
DB::connection('remote_mysql')with bound parameters againstlistings(channel/status sold, 24-month window) — replaces the CLI shell-out (F9). - Adjacency (R5): primary = lat/lng radius (listings has coordinates; else geocode subject once via the Places result): 2km metro / 10km regional, widening once if <4 candidates. Fallback = same suburb +
suburbs_agenthubneighbours. The curated 4-suburb map and postcode ±12 are deleted. - Keep the scoring model (type group, beds/baths proximity, price ratio bounds, recency) — it's sound; keep month/year display, icon-only types, rounded display prices; cap at 6 rows, free tier shows 3 (D4).
- Cache scored result per resolved property (Redis 7d). Same-property exclusion as prototype.
- Thin-market copy already good (server.mjs:368-373) — keep, minus internal wording (F10).
7.4 Rounding, adjustment, confidence
- Port
roundStep/roundMarketValue/publicEstimate(server.mjs:517-575) as-is except the seed:stableFactor("{providerRecordId}:mid", 0.008)and:rangeequivalents (D5). Result: deterministic per property, never echoing raw source values. - Confidence mapping to Stronger/Balanced/Indicative (server.mjs:531-536) stays — consumer-facing, source-agnostic.
- Fallback-tier results present with
confidence: "Indicative"and adjusted copy ("based on local market medians") — honest, not fake-precise (N1).
7.5 Budget and circuit breaker
EstimateBudget (Redis counters, daily key)
consume(): INCR estimate:budget:{Ymd}; if > cap (config, start 500) → false
breaker : 5 consecutive upstream failures → open 10 min → fallback tier
alerts : 80% budget, breaker open, budget exhausted → Telegram (existing rails)
7.6 Persistence
- No new portal tables required for P1. Estimate state lives in Redis; lead data goes to CRM via capsule as today.
- P2 adds the nurture-spec tables (
pvt_events,property_estimates— DDL already in the nurture spec §6.7, CRM side) fed by an authenticated internal POST from the portal, uuid-idempotent, with an async resolver matching email→contact and address→property. - Also P0, one-line hardening while in the routes file: add
throttle:30,1to legacyGET /api/v1/property-value(G1) and a throttle ontrack/*(G6).
8. Frontend, UX and conversion design
8.1 Mounting
- New Vite entry (pattern: vite.flow-editor.config.js) building the V3 React app to hashed assets; Blade wrapper view including resources/views/includes/analytics.blade.php (GTM) and the standard header/footer with the real phone number (1300 665 557).
- Route:
/property-estimate(public, web group). Keep the URL boring and keyword-relevant for SEO reuse on suburb pages later. - Address step: Google Places Autocomplete (reuse the config approach in resources/assets/js/map-search.js), AU-restricted, street-address types. Free-text regex remains only as a fallback validator. This is the single biggest conversion fix — free-text address entry fails constantly on real mobile traffic.
8.2 Flow and gating (D4)
LANDING ADDRESS LOADING (~3s adaptive) RESULTS (free)
hero (fixed copy) Places autocomplete real progress states range low/likely/high
single CTA → candidate pick → "matching property…" → confidence label
"checking recent sales…" 3 comparables (flag DR1)
agent questions
[Compare local agents]
│
CONTACT GATE (name/email/mobile,
APP notice + scoped consent)
│
SMS PIN VERIFY (existing flow)
│
SHORTLIST + full comparables
+ emailed report (P2 magic link)
8.3 UX changes from prototype
| Change | Detail |
|---|---|
| Loading step ~3s adaptive (was fixed 5s, F11) | Minimum 2.5s to let the credibility copy land; extends with real latency; progress rows switch on actual pipeline stages. 5s is too long for paid mobile bounce behaviour. |
| Copy fixes (F4–F7, F10) | Per §12 replacement table. Phone, stars, hero line, "WREA market data" attribution, prototype strings, internal wording. |
| Edit-address affordance | The results header shows the resolved address with the edit icon (main.jsx:200) — wire it to return to the address step (prototype renders the icon but has no handler). |
| No-data path (R9) | Keep UnavailableStep, add the suburb-tier fallback presentation before giving up: a suburb-level "Indicative" range still converts and still gates the shortlist. |
| Mobile-first QA | Primary traffic is paid mobile; the estimate grid and comparable rows must be verified at 360px. Existing CSS is close but untested on real devices. |
8.4 Uniquely-WREA layer P3 (R10)
- Agent-evidence teaser: "N agents have sold similar homes near this address in the last 12 months" (counts from WREA's agent-performance data — free tier, numbers only). This is the asset LAF/OpenAgent can't cheaply copy and makes the shortlist CTA concrete.
- Three-scenario strip: conservative / market / ambitious anchored to the range, one sentence each on what the scenario demands of the agent (buyer depth, campaign spend, method).
- Fee context: "Typical commission in {suburb}: X–Y%. The right agent should justify theirs against this estimate." (WREA fee data.)
- Keep "What to ask agents" — carry the same three questions into the CRM lead note so consultants open with them (R6).
9. Abuse controls
Layered; starting numbers to tune with real traffic. All counters on Redis (D7). Never rely on: CORS, CSRF-on-GET, client-side encoding, or any single control (the brief's list — endorsed).
| Control | Setting |
|---|---|
| Per-session value lookups | 5/day; 3 distinct addresses/session (a genuine seller checks their own home, maybe one more) |
| Per-IP (sliding window) | 10 lookups/hr, 30/day — NAT-tolerant; exceeding ⇒ challenge, not block |
| Token integrity | Single-use tombstones, 10-min TTL, session binding; replay ⇒ generic error + abuse counter |
| Cache-first economics | Cache hits don't touch upstream or budget; popular addresses cost nothing to re-serve |
| Global upstream budget | ~500 ODIN calls/day initial; breaker to suburb fallback; Telegram alerts at 80%/100% (§7.5) |
| Challenge layer (D6) | Cloudflare Turnstile on the address step from the 3rd distinct address per session, or on per-IP hourly breach. Never on first lookup. Existing reCAPTCHA v3 signal optional, never the gate. |
| Lead-form protection | Honeypot field + minimum-time-to-submit + existing Experian validation + blacklist (HomeController@checkBlacklist) + PIN attempt lockout (extend the id-keyed pin_attempts_{id} with an IP-keyed cap) |
| Monitoring | Alert on distinct-address velocity per IP/session, token replays, budget thresholds, breaker state; daily abuse digest to Telegram |
10. Tracking and full-funnel measurement
- Events (GTM dataLayer, matching existing
data-gtm-eventconventions):valuation_landing_view,valuation_address_started,valuation_address_selected,valuation_loading_started,valuation_result_viewed,valuation_comparables_viewed,valuation_agent_questions_viewed,valuation_agent_compare_clicked,valuation_contact_started,valuation_contact_completed,valuation_phone_verify_started,valuation_phone_verified,valuation_lead_created,valuation_unavailable,valuation_abuse_challenged, plusvaluation_report_emailedandvaluation_email_verified(P2). - Server-side: lead-stage events also recorded via the
track/*path (ad-blocker-proof) — after adding its missing throttle (G6). Opaqueestimate_session_idon every event for funnel stitching. - Dimensions (banded only): suburb, state, value_band ($250k bands), confidence_band, comparable_count_band, device_type, traffic_channel, campaign, result_available, lead_stage.
- Never sent to analytics: raw address, exact estimate values, email, phone, provider anything.
- Weekly readout joins GA4 funnel to CRM lead outcomes by estimate_session_id → lead id (existing weekly growth report rails).
11. Data minimisation rules
11.1 Response whitelist (transformer-enforced, N2)
{
available, formattedAddress, suburb, stateCode, postCode,
category, // consumer label, e.g. "Detached home"
beds, baths, carSpaces,
estimate: { low, likely, high, confidence, display{} }, // rounded+adjusted only
narrative,
comparableSales: { available, summary{count, displayMedianPrice, suburbs, message},
rows[{address*, suburb, beds, baths, cars,
displayPrice, displaySoldMonth, matchReasons[]}] }
} // * address display scope subject to DR1 verification outcome
Excluded always: provider names/IDs/paths, raw estimate values, match scores, raw confidence strings, internal query detail, land size, exact sale dates.
11.2 Logs, analytics, bundle
- Application logs: HMAC-hash the address (server key); log suburb + value band for debugging. Full address exists only in the CRM lead record (service basis).
- User-facing errors: generic; no internal wording (F10).
- Bundle: provider config server-side env only; CI release gate: grep built assets and a sample API response for provider/source terms — fails the build on hit (currently clean, F12; keep it that way).
12. Compliance and copy rules
| Current (prototype) | Production replacement |
|---|---|
| "Know your likely sale price before you choose an agent" (main.jsx:68) | "Understand your home's likely price range — then compare the agents who can defend it." |
| "Estimate benchmark from WREA market data" (main.jsx:219) | "WREA market benchmark, based on recent market data" (no proprietary-data claim over a licensed source) |
| ★★★★★ trust row (main.jsx:76) | Remove, or replace with a substantiated, sourced rating (real count + platform) |
tel:133033 (main.jsx:52) | tel:1300665557 (portal standard) |
| Prototype/privacy strings ("details stay local in this prototype…", main.jsx:349-351) | APP collection notice + scoped consent checkboxes (marketing vs service contact) + real Terms/Privacy links; state SMS verification will occur (the prototype's note is good — keep the honesty) |
Standing disclaimer (under the estimate, short form)
- Never use "valuation" as the consumer-facing product noun — in Australia it implies licensed-valuer work. "Estimate" / "benchmark" / "price range" only. Never "guaranteed", never exact-price promises.
- Comparables must not imply agent or portal endorsement of the estimate.
- Emails downstream use benchmark language exclusively (nurture spec rule, §17).
13. Testing strategy
Portal conventions: PHPUnit in tests/, Playwright in e2e-tests/tests/portal/ and …/cross-system/ (capsule POST interception pattern; d_addr param skips the Places step).
13.1 Unit (services)
EstimateTokenTest— signature tamper, TTL expiry, wrong session, single-use replay ⇒ all rejected; happy path resolves candidate.EstimateDeterminismTest— same provider record id through 5 address formats ⇒ identical low/likely/high (R4); rounding bands at $10k/$25k/$50k steps; range never crosses zero (port of server.mjs:556-560 guard).EstimateBudgetTest— cap enforcement, breaker open/close, fallback tier engaged, alert dispatch.ResponseWhitelistTest— transformer output contains exactly the §11.1 keys; a poisoned upstream payload with provider fields cannot leak (assert absence by denylist too).ComparableSalesServiceTest— parameterised query (no string SQL), same-property exclusion, price-ratio bounds, thin-market path, DR1 flag off ⇒ null section; adjacency: metro radius vs regional widening.FallbackTierTest— upstream 5xx ⇒ suburb-tier estimate with "Indicative" confidence and adjusted copy (N1).
13.2 Feature (HTTP)
- Search→value→lead happy path with real validation rules asserted (reject bad email/phone server-side — do not inherit G3).
- Throttle boundaries return 429 with generic bodies; abuse counters increment; Turnstile required exactly from the 3rd distinct address.
- Lead POST attaches the valuation summary to the capsule payload; PIN flow triggered.
13.3 E2E (Playwright)
- Full funnel on the sample property (18 Tooke Street, Bar Beach) with the capsule POST intercepted (existing cross-system pattern).
- No-data address path; comparables-disabled (DR1 flag) rendering; mobile viewport (360px) pass.
13.4 Release gates (CI)
- Provider-term grep over built assets + one live sample response (N2).
- Prototype-artefact grep:
133 033,example.invalid, "prototype", "data lane" ⇒ build fails (N5). - Load test: value endpoint at cache-miss worst case, 20 rps burst — p95 within N4, budget/breaker behave.
14. Phased delivery plan and launch checklist
Phase 0 Rails — ~3–4 dev days
- Services + endpoints + token design + Redis caches + budget/breaker (§7). Response transformer + whitelist tests.
- Throttle the legacy
/api/v1/property-valueandtrack/*routes (one-liners, G1/G6). - CI release gates (provider grep, artefact grep).
- Thomas DR1 listings verification underway; DR2 ODIN tier check noted. Comparables behind
estimate.comparables_enabled.
Exit: unit/feature tests green; a scripted scrape attempt (100 addresses) is throttled/challenged and costs ≤ budget; no provider terms in any response.
Phase 1 Controlled beta — ~4–6 dev days
- Vite island + Blade route + Places autocomplete + copy fixes (§8, §12). Loading step adaptive.
- Lead flow wired (capsule + PIN + CRM valuation summary). GTM events live (§10).
- Playwright suite; mobile QA; ≥5 non-Newcastle market checks (R9 begins).
- Beta exposure: unlinked URL + allowlist, then one paid-search ad group at low budget.
Exit: full-funnel E2E green; first real leads carry valuation summaries into CRM; funnel events reconcile between GA4 and server-side track; zero provider leakage in production responses (spot-checked).
Phase 2 Public launch — ~1–1.5 dev weeks
- Turnstile risk-triggered challenge; abuse alerting/digest live.
- Emailed report + magic-link email verification (R7);
pvt_events/property_estimatesemission to CRM (R11, §17). - DR1 outcome applied (comparables on, scoped, or gated). Compliance copy final review. Load test passed.
- Launch on paid + organic landing; suburb-page embeds prepared but not required.
Exit: public traffic, complaint/abuse metrics within thresholds; nurture events flowing; weekly readout includes the tool funnel.
Phase 3 Uniquely-WREA layer + growth — ongoing
- Agent-evidence teaser, scenario strip, fee context (R10). A/B: gate placement, loading duration, hero copy.
- Retargeting audiences by suburb/value band. Suburb-page + agent-fee-page embeds. Licensed-AVM swap option behind the interface if DR posture changes.
Launch checklist (condensed)
| Before first production test | Before public launch | Can follow after launch |
|---|---|---|
| POST/token endpoints only · Redis caches/limits/budget · transformer whitelist + tests · CI grep gates · prototype artefacts purged (F4–F7, F10) · determinism fix (F3) · parameterised comparables (F9) · disclaimer copy · legacy route throttled · DR1 flag wired | Turnstile + alerting · real adjacency replaces postcode±12 (F8) · thin-market UX in ≥5 markets · PIN + capsule E2E · CRM summary verified · privacy/T&Cs reviewed · load test · DR1 verification recorded · DR2 tier check recorded | agent-evidence teaser · scenario strip · fee context · emailed-report A/Bs · suburb embeds · retargeting audiences · licensed-AVM swap option |
15. Dogfooding and rollout protocol
- Local first: the prototype stays as the reference implementation; the portal build is verified against it on the sample property (18 Tooke Street, Bar Beach — expected range parity after the determinism fix).
- Staging/allowlist: Thomas + Felix run the full funnel on their own addresses; verify CRM lead note content, PIN SMS, GTM events in the GA4 debug view.
- Scrape-yourself test: before any public exposure, run the scripted 100-address scrape against staging and confirm throttles/challenges/budget behave — this is the release gate for §9, not an afterthought.
- One ad group beta: low-budget paid-search ad group → unindexed URL; watch conversion vs the standard funnel, upstream spend, abuse digest, and consultant feedback on lead quality for one week.
- Kill criteria (pre-agreed): upstream budget exhausted 2 days running from non-converting traffic; complaint about estimate accuracy from a listed vendor; any provider-term leakage; conversion below the standard funnel after 200 clicks — pause and review, don't tune live.
- Weekly readout in the growth report: funnel counts, cost per verified lead vs the standard funnel, estimate→lead rate by value band.
16. Risks and safeguards
| Risk | Safeguard |
|---|---|
| Scraping / free-oracle abuse | Token design (§7.2), layered limits + Turnstile (§9), scrape-yourself release gate (§15.3) |
| Upstream cost blowout / ODIN availability | Cache-first + daily budget + breaker + suburb fallback tier (§7.5, N1); alerts to Telegram |
| Data-rights exposure | DR1/DR2 owner-verified gates (§2); comparables config flag; source interfaces for swap/scope; adjustment layer prevents raw-value echo |
| Provider/source leakage | Transformer whitelist + CI grep gates (N2, §13.4); banded analytics only (§10) |
| Trust damage from inconsistent estimates | Determinism fix keyed on resolved property (D5/R4) with dedicated test |
| Wrong-geography comparables outside Newcastle | Real adjacency (R5) + ≥5-market QA (R9) before public launch |
| ACL / misleading-conduct exposure | Copy replacement table + disclaimer (§12); no "valuation" noun; no fake ratings; substantiated claims only |
| Privacy (APPs) | Collection notice, scoped consent, hashed addresses in logs, PII only in CRM (§11, N6) |
| Conversion damage from over-protection | Never challenge first lookup (N3); value-first gating (D4); limits tuned generous-per-IP, tight-per-session |
| Copying portal anti-patterns | G1–G6 documented; real FormRequest rules and Redis stores are spec requirements, not suggestions |
17. Email nurture integration
This tool is the email-capture engine for the nurture operating system (reports/wrea-email-nurture-build-spec-2026-07-02.html). Contract (that spec, §12; tables §6.7):
- Events emitted:
value_requested,value_viewed,email_captured,email_verified(magic link),consent_given(scopedmy_property|local_market|both, with wording version/ts/source URL),value_refreshed(delta),return_visit,cost_to_sell_used,report_requested/report_unlocked. - Estimate history: every shown estimate lands in
property_estimates(low/mid/high, source, confidence, sales-support count) so "your estimate changed" emails cite real prior values and thin-data suburbs never trigger "market moved" claims. - Enrolment wiring: resolver outcomes call the nurture
EnrolmentService— verified + consented + no meeting-agents within 24h ⇒ rescue lane; consent scopemy_property⇒ value-refresh watch. No direct sends from this tool, ever — everything flows through the nurture eligibility/frequency-cap/holdout rails. - Location normalised at capture (canonical state/suburb/postcode from the Places result) — prevents recreating the CRM's dirty-location problem.
- All downstream email copy uses benchmark language (§12).
18. Open questions / decisions needed
- DR1 outcome — listings public-domain verification result decides comparables display scope (full addresses / street-name-only / lead-gated). (Thomas — in progress per §2)
- DR2 tier check — confirm ODIN subscription permits consumer-facing derived-estimate display; record here. (Thomas)
- Initial upstream budget — spec says 500 calls/day; confirm against ODIN pricing/quota. (Thomas + Felix)
- Turnstile account — Cloudflare already in front of the portal? If not, decide Turnstile-standalone vs staying with reCAPTCHA-but-enforced. (Felix)
- Beta traffic source — which paid ad group carries the beta, and what daily click budget. (Thomas)
- Suburb fallback presentation — show suburb-tier "Indicative" range when the property lookup fails, or go straight to Unavailable? Spec recommends showing it (N1). Confirm. (Thomas)
19. Appendix: key code references
19.1 Prototype (reference implementation)
| Thing | Where |
|---|---|
| Candidate matching / scoring | server.mjs:87-138 |
| Rounding + adjustment + public estimate | server.mjs:503-575 (fix seed per D5) |
| Comparables query/scoring/summary | server.mjs:262-501 (replace CLI + geography) |
| Public payload shape | server.mjs:622-641 (basis for §11.1 whitelist) |
| Flow/state machine + copy | src/main.jsx:393-448; copy fixes at :52-54, :68, :76, :219, :349-351 |
19.2 Portal (verified 2026-07-02, branch pre_production)
| Thing | Where |
|---|---|
| Legacy unprotected valuation route (throttle it) | routes/web.php:334 → ApiController@calculatePropertyValue (:1221+) |
| Suburb-tier valuation fallback (reuse) | app/Services/PropertyValuationService.php (REA cache → OTH → suburb_info → state defaults, all remote_mysql) |
| api/v2 throttles + CORS | routes/api.php:16-75; app/Http/Kernel.php:37-41; app/Http/Middleware/CorsMiddleware.php |
| SMS PIN + lockout (reuse) | app/Http/Controllers/Api/LeadController.php (sendPinVerification/verifyPin, pin_attempts_{id} :184-195) |
| Lead capsule → CRM (reuse) | ApiController@postCapsuleTriggerEvent (:691) → App\Services\CapsuleApi; blacklist via HomeController@checkBlacklist |
| Dormant reCAPTCHA (signal only) | HomeController.php:36-59; enforcement commented ~:334 and ApiController ~:170 |
| Places autocomplete (reuse) | resources/assets/js/map-search.js:13,77 |
| Cache pattern (reuse, on Redis store) | app/Services/SuburbsApi.php:22,91,161 (Cache::remember) |
| Vite island precedent | vite.flow-editor.config.js + package.json flow-editor scripts |
| GTM include + event conventions | resources/views/includes/analytics.blade.php; data-gtm-event e.g. compare_agents.blade.php:164 |
| E2E patterns | e2e-tests/tests/portal/, …/cross-system/ (capsule intercept, d_addr skip) |
19.3 Related documents
- docs/wrea-property-estimate-v3-production-review-brief-2026-07-02.md — the review brief this spec answers
- artifacts/wrea/valuation-prototypes/wrea-property-estimate-tool-v3-2026-06-30/ — prototype (kept as reference; never deployed)
- reports/wrea-email-nurture-build-spec-2026-07-02.html — companion nurture spec (shared
pvt_events/property_estimatescontract) - artifacts/wrea/competitor-reviews/local-agent-finder-2026-06-26/…/report/index.html — LAF teardown (value-first sequencing evidence)
- docs/odin-onthehouse-valuation-fallback-investigation-2026-06-22.md — valuation source hierarchy background
Spec authored by Claude (Fable 5) on 2026-07-02, expanding the adversarial production review delivered the same day, from read-only review of the V3 prototype and the wrea_main codebase. Data-rights posture per Thomas's direction: listings public-domain verification owned by Thomas before use (DR1); ODIN is a separately paid subscription (DR2). Verify code references against the current pre_production branch before implementation. v1.0.