# The Doozer Brain — public edition

> Generated live from the running platform at 2026-09-13 21:13 UTC. This is not a snapshot.
> Every fetch re-reads the brain, so this document always reflects what Doozer
> currently believes about itself.

**What this is.** Doozer's complete working knowledge in
[Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog)
(OKF): plain markdown nodes with YAML frontmatter, linked into a knowledge
graph. It is meant to be self-contained. An AI (or a person) who reads this
bundle should come away understanding the whole Doozer world: the idea, the
ecosystem, the economics, the growth engine, the marketing machine, and where
it is going next.

**What Doozer is, in one line.** One codebase turns any domain into a complete,
AI-generated business website, and the network of those sites into a local
discovery layer. Named after the industrious builders from Fraggle Rock.
Tagline: *We Build Because.*

**How to read it.** Start with The Mission, then The Doozer Story, then The
Architecture, then The Network, then The Value Model, then The Observed World.
Those six give you the spine. Everything after is detail hanging off it.

**Scope of this edition.** 93 nodes published. 13 held back.
The omissions are operational security only, never product thinking: things
like infrastructure failure modes, the auth shape of internal endpoints, and
the mechanics of claim-code verification. Nothing about the strategy, the
economics, or the craft has been removed. If a system seems to reference
something you cannot see, that is why, and it is not load-bearing for
understanding Doozer.

**Provenance.** Authored and maintained by Jon Berry (BerrySmart), the
platform's owner and operator. Doozer is proprietary software; this brain is
published so that people and AI systems can understand and discuss it
accurately. It is documentation, not an invitation to copy the platform.

**Machine-readable siblings.** `/brain.txt` (this document as plain text),
`/brain.json` (this bundle, structured),
`/llms.txt` (short orientation), `/.well-known/doozer-atlas` (per-entity live
data projection).

---
## Contents


**Root**

- The Doozer Brain — Doozer's complete knowledge in Open Knowledge Format — the idea, direction, systems, processes, value, and vocabulary behind the entire platform.

**Decisions**

- Adopt Open Knowledge Format for the Doozer brain — Why Doozer organizes its knowledge as an OKF bundle, scoped to the stable core.
- Doozer vs Doozer+ (the two-tier model) — The product direction splitting a free templated info page from a paid bespoke full site, and why the split exists.
- Icon Taste Rule — Primary UI uses crafted SVG icons, never clipart/emoji; color is reserved for state, not for buttons.
- Implicit Verification (no claim system) — Business ownership is an email match, not an account/claim flow. Load-bearing platform architecture.
- Magellan — cross-service business identity mapping — Doozer cross-links businesses by verified external IDs (Google Place, Ticketmaster, future services), never by name/city/state. Magellan is the mapping spine. Decided June 2026; not yet built.
- No Dark Patterns (the Doozer ethos) — Pure intent, organic, intelligence-driven. The lens for every product and ranking decision.
- The Cloud Fabric — automatic clouds over the observed network — The connective-tissue vision. Once every local business exists as a Doozer site (observed / painted on demand), Clouds form automatically around topic, area, and non-obvious relationships, cross-linked into one self-reinforcing, claimable, quality-indexed network that serves both businesses and users.
- The Doozer Story — The canonical WHO/WHAT/WHEN/WHERE/WHY/HOW of Doozer, distilled. The single source the Postcards, landing copy, and press materials draw from.
- The Mission (the north-star quality bar) — Why Doozer exists and the non-negotiable quality bar every site must clear — the lens for every build decision.
- The Observed World (render-on-demand + the lead flywheel) — Doozer already contains every local business in spirit; a site or cloud becomes real only when observed. Each materialized site is simultaneously inventory and a pre-built lead. This reframes what to build, what to measure, and how money is made.
- The SEO Cliff and Recovery (why we noindex until quality-gated) — The May 2026 elks.club algorithmic demotion for scaled thin content, and the enrich-then-index discipline it burned into the platform.
- The Value Model (how Doozer creates and captures value) — Where Doozer's value comes from and how it is captured, so an AI understands the economic engine, not just the software.
- The writer model is chosen on accuracy, not price — GPT-5.4 mini writes Doozer's business copy. Decided 2026-08-22 by an 11-niche counterbalanced bake-off in which the more expensive model fabricated a verifiable fact on a live page.

**Systems**

- AI Discoverability — being the local layer AI agents cite — How Doozer makes itself legible to AI agents and AI-powered search (ChatGPT, Perplexity, Claude, Gemini, Copilot, DuckDuckGo). The DDG=Bing insight, the explicit crawler welcome, llms.txt, and the machine-readable stack (schema.org JSON-LD + Atlas + design.md) that already existed.
- AI Postcards — Factory, Theme Studio, and Campaigns — How Doozer builds and mails postcards. The Factory assembles accurate cards deterministically (real screenshot/code/QR, no per-card AI cost); the AI Theme Studio designs vision-gated backgrounds; campaigns give each card a purpose + audience. Claim Your Site is the first campaign.
- Best AI for the Job — value-aware model selection — How Doozer decides which AI model to use for each function, when, why, and for what — balancing quality against cost by the value of the work, learning from its own scores, and staying provider-diverse on purpose.
- Billing — Product-agnostic Stripe billing — one connection sells everything, entitlements flow to lb_users.tier.
- Cloud Header Images — Every cloud (and root) gets a header. Real image > Keeper opt-out gradient > a designed per-cloud placeholder. AI generation is the upgrade path.
- Clouds (curated directories) — The cloud engine — how niche/region/criteria directories are built, accessed, and personalized, including the Cloud Factory and my.doozer.cloud.
- Content Generation Pipeline (providers, models, timeouts) — How AI site prose is actually produced — the two provider paths, model config, the 90s timeout, the anti-boilerplate footer, and every failure mode that bit us.
- Context-city poisoning (never name a place after where you found it) — Two independent creation paths named businesses after the city they were DISCOVERED from rather than where they are, putting the wrong town in 239 URLs. The pattern, the fix, and the audit.
- Design Standards and the Design Mirror — One shared rule-set the generator, renderer, and admin self-diagnostic all consult — plus the mirror that scans and heals the fleet.
- Directory Multi-View Framework — The shared Grid / List / Map / Events view system that every directory surface (root, Cloud, NBMN) renders through.
- Doozer Atlas (outward AI emission) — The public, per-entity knowledge contract Doozer emits so external AIs can ingest the network — the outward-facing twin of the inward Brain.
- Doozer Digest (AI daily newsletter) — An AI-written daily email — a public reader edition and an operator admin edition — built from real platform signals.
- Doozer Town and the Board Game view — doozer.town builds a page for every town in America the first time somebody asks for it, sorts its businesses onto ten colour groups, and can show any Doozer Cloud as a property board. Cost model, grouping rules, the edge blocker, and what to watch.
- Email Open Tracking — A 1x1 pixel in every outbound email; opens recorded with device/geo/bot-flag; three operator alert signals plus data for the digest.
- Enrichment Store Links (commerce "Shop Now" CTAs) — Turning a business's real online store into a tasteful commerce CTA, with a confidence gate so we never send a visitor to a bare marketplace homepage.
- Events to machines — a live event is only half published until a machine can read it — The pipeline that takes real events from public feeds onto a Doozer page AND emits them as schema.org Event, plus the timezone rule that makes the data correct. The foundation for gohe.re Gatherings.
- Fact-Guard (the accuracy net) — A conservative healer that catches and corrects factual drift in AI-generated content (e.g. lodge/post identity numbers) at both the DB boundary and render.
- Fact-guard — org identity numbers, and the hole a conservative regex left — How the accuracy net corrects a wrong Lodge/Post number, why the safe-looking regex silently no-opped, and why over-correcting it is worse than the original bug.
- Gatherings + Social (events, check-ins, the human layer) — The roadmap surfaces that add real-time human signal — events/gatherings (GoHe.re), check-ins (wuz.ai), and how they enrich every business site.
- Hot Leads — the internal Doozer-sales CRM — Businesses are OUR prospects. Doozer-awareness scoring (claimed + postcard engagement + leads we deliver + live site) ranks who is warm enough to pitch Doozer+. The Internal Affairs Division of LeadBuilder.
- IndexNow (instant crawl notification) — Push index-eligible Doozer URLs to Bing/Yandex the moment they cross the quality gate, so fresh and changed pages get crawled in hours instead of weeks. Bing-first hedge against the Google scaled-content classifier that cliffed elks.
- Lead Capture (the revenue pipeline) — How a visitor's message or call becomes a routed, deliverable lead — the shared nbmn_lead_requests pipeline, auto-routing, no-email self-heal, and LeadBuilder handoff.
- LeadBuilder (the lead-intelligence platform) — Where every captured lead, call intent, and cross-site referral flows to become revenue — the sibling product that turns Doozer traffic into qualified leads.
- Live Music Tonight (livemusictonight.com) — A demand-sensing live-music discovery directory built on the Vertical Directory engine, fed by the Ticketmaster/Marquee feed, that captures visitor interest as a graph to decide which venue sites to build next.
- Magellan and the feed pond — bound IDs turn sites into living entities — The business_id_map spine (built v2.2.549) plus the feed adapters that hang off it. A bound external ID is what lets a generated page carry current, authoritative, self-maintained content instead of static prose.
- Marketing (build in public) — The composer that drafts social posts from real platform signals in the founder's voice, and the auto-draft review queue.
- Money Clouds (affiliate vertical directories) — Generalizing the Live Music Tonight pattern — any niche with an affiliate feed becomes a revenue-share vertical directory, bound across APIs by Magellan.
- NearByMeNow (the traffic + directory layer) — The public "what's near me" surface that indexes the whole fleet, drives visitors back to business sites, and captures demand as leads.
- Network Growth — "More places nearby" (build-on-tap, gated) — How the network grows organically from real local intent — visitors spin up the next site — and the two per-root guardrails that keep that growth clean.
- Niche matching and live discovery
- Per-Location Data Enrichment (the true voice of a place) — Site voice is signal-in, signal-out. Every per-location signal (reviews now; events, gatherings, check-ins, comments, photos coming) enriches the location site, the database, AND every downstream consumer of the Doozer Data pipeline.
- Per-User Location Hotspots — A derived projection of a signed-in user's primary/secondary location centroids (home/work) from their own signals, used to personalize near-me defaults, reminders, and the Digest.
- Postcard targeting (the Postcard Queue) — Who deserves a paid claim postcard — exclude committee-orgs and franchises, rank owner-operated marketing-spenders.
- Postcards — The living-story system. Short or long dispatches that tell Doozer's WHO/WHAT/WHEN/WHERE/WHY/HOW/VISION, stamped to the moment they were written.
- Promo Codes + the one-time claim offer — Reusable Stripe-backed discount codes (the promo generator) and the truly-one-time Doozer+ 50%-off-for-life offer shown on claim. Money-safety, single-fire, and how they map to Stripe coupons.
- Prospector (discovery + demand-driven growth) — How Doozer finds new businesses to build — the Grand Opening Finder, Cloud Radar, and the hybrid Google Places discovery engine.
- Regeneration + Foundation (keep 2,400 sites fresh and safe) — The unified regeneration engine, version stamping, and staleness detection that let the whole live fleet be improved without taking anything down.
- Shortlinks + QR routing (doozer.link) — Dynamic short URLs at doozer.link. The code encodes doozer.link/<slug>; the destination is editable, so a printed QR can be re-pointed without reprinting. The routing layer for the observed-world lead flywheel.
- Shortlinks and QR (doozer.link) — One permanent, claim-aware doozer.link + QR per site — the printed asset that flips from the claim page to the real site on claim.
- Source-of-truth cache (read the real website) — A business's official website cached as markdown (Jina Reader) — the accuracy foundation for enrichment; enrich content, never overwrite location.
- Thanks.io Connector (direct mail, two-way) — How Doozer sends physical mail via Thanks.io and feeds the results back into intelligence — claim postcards, radius audience buying that reverse-engineers new sites, and delivery/scan webhooks.
- The /now surface and the dense directory — the app NearByMeNow already almost was — One button, the nearest real places, and Build-it-on-the-spot. Plus the measured teardown of the category page that was hiding its own businesses behind 855px of controls.
- The Architecture (one file, unlimited sites) — How a single index.php serves every business site, root directory, Cloud, and NearByMeNow page in the network by detecting the host and routing the request.
- The Chamber Chamber
- The Doozer Brand System (ecosystem identity kit) — The canonical design identity for the whole Doozer ecosystem. Every human-authored surface (doozer.work, claim/auth/admin, roots, LiveMusicTonight, GoHe.re, Wuz.ai, AwesomeAddresses) shares one skeleton (neutrals, type, chrome, voice) and varies only its accent hue, so the network feels like one building with differently-lit rooms. Use this to build production-ready UI fast and on-brand.
- The Doozer Flywheel — How Doozer, NearByMeNow, LeadBuilder, and Prospector reinforce each other into a self-feeding local-business engine.
- The Doozer Score — the Stick, the Tower, and the leverage between them — One tracked number for whether Doozer is getting cheaper and more valuable at the same time. Stick = cost of 100 sites, Tower = replacement value of 100 sites, Score = Tower / Stick.
- The Elks Root Theme (bespoke root identity, a case study) — How an entire root domain gets a hand-authored identity layer over the Muse engine — the elks.club star-spangled theme — as the pattern for premium root branding.
- The Growth Engine — observe → serve → convert → learn — The map of Doozer's acquisition + monetization machine. How observing a business, painting it a real site, sending it leads, tracking every touch, and making the RIGHT offer at the RIGHT moment turns into revenue — and how the intelligence layer decides who to reach, how, and when. The purpose, the smarts, and the wiring, with links to every component.
- The Muse System — Doozer's AI-driven design engine — palette, typography, layout, and section variants that make each business site its own.
- The Muse v2 layer, and the brief that feeds it
- The Network (the whole Doozer ecosystem) — A map of every product in the Doozer world and how they interconnect into one referral loop, so an AI can see the breadth of the platform at a glance.
- The Niche Color System — One source of category color for the whole network — the design primitive that makes cards, pills, spines, and markers coherent across every surface.
- The Photo Pipeline (harvest, store, serve) — How business photographs are referenced from Google, bought once on first view, stored as objects on DO Spaces, and served back through a predictable cache key that lets any page ask "do we already own this?" before spending.
- The Section Framework — How a business page is assembled from pipeline section slots and per-variant partials a Muse selects.
- The Shared Business Card — The one card renderer used across NBMN, cloud, and root directories, with a pluggable toolbar and category-aware call intent.
- The Weather Widget — A non-intrusive "plan your visit" weather card on business sites — current conditions plus a 7-day forecast for the business's own location, free by default.
- Two gates, and the category catalog
- event-feeds
- machi

**Craft**

- Category-aware call intent — The tap-to-call chips are tailored to why you'd actually call THIS kind of business — reserve a table at a restaurant, get a quote from a plumber, check if the car is ready at a shop.
- Cross-root identity without third-party cookies — One login has to work across doozer.work, elks.club, nearbymenow.com and every other root — but third-party cookies are dead. So identity rides a first-party token handoff, not a shared cookie.
- Decision vs delivery (the muse was almost never the problem) — Nine defects found in one week where the design system chose well and the render layer quietly discarded the choice. The shapes these bugs take, why they never error, and how to find them.
- Derive the URL, don't trust the stored domain — A saved business's link is always computed from the business's currently-active domain, never read from the domain id we stored when it was saved — because that stored id goes stale and poisons the link.
- Fix the source, never trap the symptom — When the output is wrong, fix what produced it — the prompt, the data, the generator — not the output. No filters, no gates, no find-and-replace patches on the way out.
- Inline the bytes, never the proxy URL — When handing an image to an AI provider, send the actual bytes from our side. Never hand it one of our own proxy URLs to go fetch — that is a timeout and a token burn waiting to happen.
- Price the pull before you pull — A data refresh is a bill. Before re-pulling from any paid source, check what each pull costs, reuse the cache, dry-run the total, and pull only the unavoidable remainder.
- Self-healing email backfill — I tried last week and it didn't work. I tried today and it works." The site quietly tried harder to find the business's contact email because it noticed the same person kept coming back.
- Skeleton first, index later — Show the visitor something instantly; show Google nothing until the page is actually good. Two different audiences, two different clocks.
- The distraction problem (teach without nagging) — How do you explain "this is your listing, and here's how to manage it" to the one owner who needs it, without cluttering the page for the thousand visitors who don't? Tier the education by how much the reader has signaled they care.
- The hostile CSS environment — Every shared component treats the page it lands on as an adversary, because the muse's own AI-authored CSS is actively trying to restyle it.
- The logo knows its own shape — A business logo is measured, not assumed. A square transparent emblem, a wide wordmark, and a tall banner each get different treatment, because one layout cannot flatter all three.

**Processes**

- Content Generation and Healing — How AI site content is generated, why it sometimes fails, and how it is retried, backfilled, and healed.
- Site Creation — How a domain becomes a live business site — resolve the place, persist the business, pick a Muse, queue content.
- The model handoff — How one model version hands Doozer to the next — the tradition, the ritual, and the artifacts that carry continuity across upgrades.

**Glossary**

- Cloud — A criteria-based directory assembled OVER existing business sites (a town, a niche-in-a-state, a personal collection).
- Family (Design Direction Family) — A hand-authored designer recipe the Muse auto-generator draws from, tagged by which niches it suits.
- Muse — A per-business design recipe (palette, type, layout, section variants) applied at render.
- Niche — A business category slug; the unit of directory grouping and recommendation. Maps from Google place types.

---

# The Doozer Brain

`index.md` · type: Index · tags: okf, brain, index

> Doozer's complete knowledge in Open Knowledge Format — the idea, direction, systems, processes, value, and vocabulary behind the entire platform.

# The Doozer Brain

This is Doozer's knowledge in [Open Knowledge Format](decisions/okf-adoption.md) (OKF):
plain markdown files with YAML frontmatter, linked to each other to form a knowledge
graph. It is the durable "brain" behind the platform, and it is meant to be
**self-contained**: an AI (or a person) reading this bundle should come away
understanding the whole Doozer world — the idea, the direction, the processes, and
the value. It is additive to `MEMORY.md` (fast recall) and `CLAUDE.md` (project
instructions); see [how to grow this brain](AGENTS.md).

**Just upgraded to a new model?** Read **`NOTES-FOR-NEWEST-MODEL.md`** (repo root) first —
the living handoff letter from the previous model — then follow the reading order below.
See [The model handoff](processes/model-handoff.md) for the tradition.

**New session? Read [Start here — working with Jon](working-with-jon.md) first** — the
portable working agreement: how we work, the house style, keeping a brain, the admin
skeleton, the dev channel, and the stack that's only a key away. (A verbatim portable
copy is at `docs/start-here-working-with-jon.md`.)

## Start here (read in this order)

1. [The Mission](decisions/the-mission.md) — why Doozer exists + the non-negotiable
   quality bar (trusted / working / accurate / tasteful / useful / good).
2. [The Doozer Story](decisions/the-doozer-story.md) — the origin and the idea.
3. [The Architecture](systems/architecture.md) — one `index.php` serving unlimited
   sites; what a Doozer site actually IS.
4. [The Network](systems/the-network.md) — every product in the ecosystem and how
   they interconnect (the breadth).
5. [The Value Model](decisions/the-value-model.md) — how value is created and
   captured (the economics).
6. [The Flywheel](systems/the-flywheel.md) — how it all compounds.
7. [Doozer vs Doozer+](decisions/two-tier-model.md) — the free/paid direction.
8. [The Observed World](decisions/the-observed-world.md) — render-on-demand: every
   local business exists once observed, and each materialized site is both inventory
   and a pre-built lead (the self-funding flywheel).

## Organization

- **craft/** — [the clever parts](craft/index.md): the non-obvious details that make
  Doozer different, where the interesting thing is the WHY.
  [Category-aware call intent](craft/category-aware-call-intent.md),
  [the hostile CSS environment](craft/hostile-css-environment.md),
  [skeleton first, index later](craft/skeleton-first-index-later.md),
  [self-healing email backfill](craft/self-healing-email-backfill.md),
  [price the pull before you pull](craft/price-the-pull-before-you-pull.md),
  [fix the source, not the symptom](craft/fix-the-source-not-the-symptom.md),
  [inline the bytes, not the proxy URL](craft/inline-the-bytes.md),
  [cross-root identity](craft/cross-root-identity.md),
  [derive the URL, don't store it](craft/derive-dont-store-the-active-domain.md),
  [the logo knows its own shape](craft/adaptive-logo-shape.md),
  [the distraction problem](craft/the-distraction-problem.md),
  [decision vs delivery](craft/decision-vs-delivery.md). A growing genre.
- **systems/** — how things work:
  [Architecture](systems/architecture.md), [The Network](systems/the-network.md),
  [NearByMeNow](systems/nearbymenow.md), [Network Growth ("More places nearby")](systems/network-growth.md),
  [Clouds](systems/clouds.md),
  [Directory multi-view framework](systems/directory-framework.md),
  [Muse System](systems/muse-system.md), [Design Standards](systems/design-standards.md),
  [The Brand System (ecosystem identity kit)](systems/brand-system.md),
  [content generation pipeline](systems/content-generation.md),
  [Best AI for the Job (value-aware model selection)](systems/best-ai-for-the-job.md),
  [Fact-Guard](systems/fact-guard.md),
  [per-location data enrichment](systems/location-data-enrichment.md),
  [Weather Widget](systems/weather-widget.md),
  [shared business card](systems/shared-business-card.md),
  [section framework](systems/section-framework.md),
  [Lead Capture](systems/lead-capture.md), [LeadBuilder](systems/leadbuilder.md),
  [billing](systems/billing.md), [Money Clouds (affiliate)](systems/money-clouds.md),
  [Prospector](systems/prospector.md), [Marketing](systems/marketing.md),
  [Shortlinks + QR (doozer.link)](systems/shortlinks.md),
  [**The Growth Engine — the acquisition + monetization map**](systems/growth-engine.md),
  [AI Postcards (Factory + Theme Studio + campaigns)](systems/ai-postcards.md),
  [Thanks.io connector (direct mail, two-way)](systems/thanksio.md),
  [Promo Codes + the one-time claim offer](systems/promo-and-offers.md),
  [Hot Leads — internal Doozer-sales CRM](systems/doozer-prospects.md),
  [Machi (the marketing brain)](systems/machi.md),
  [Event Feeds (Marquee + the multi-source plan)](systems/event-feeds.md),
  [IndexNow (instant Bing crawl)](systems/indexnow.md),
  [AI Discoverability (llms.txt + AI-crawler welcome)](systems/ai-discoverability.md),
  [Admin Tool Inventory (audit + cockpit)](systems/admin-tool-inventory.md),
  [Doozer Digest](systems/doozer-digest.md),
  [Email Open Tracking](systems/email-tracking.md),
  [Identity + Auth](systems/identity-and-auth.md),
  [Doozer Comms (Telegram)](systems/doozer-comms.md),
  [Regeneration + Foundation](systems/regeneration.md),
  [Atlas (outward AI)](systems/atlas.md),
  [Gatherings + Social](systems/gatherings-and-social.md),
  [Live Music Tonight](systems/livemusictonight.md),
  [The Elks Root Theme](systems/elks-root-theme.md),
  [Niche Color System](systems/niche-color-system.md),
  [Enrichment Store Links](systems/enrichment-store-links.md),
  [per-user hotspots](systems/user-hotspots.md), [Postcards](systems/postcards.md),
  [cloud headers](systems/cloud-headers.md),
  [hosting infrastructure](systems/hosting-infrastructure.md),
  [Admin Health Tools](systems/admin-health-tools.md),
  [Dev API + Site Quality Score](systems/dev-api.md),
  [free-tier render gotchas](systems/free-tier-render-gotchas.md),
  [the 521 root cause](systems/the-521-root-cause.md),
  [shortlinks and QR (doozer.link)](systems/shortlinks-and-qr.md),
  [source-of-truth cache](systems/source-of-truth-cache.md),
  [postcard targeting](systems/postcard-targeting.md),
  [fact-guard: org identity numbers](systems/fact-guard-identity-numbers.md),
  [context-city poisoning](systems/context-city-poisoning.md),
  and [the flywheel](systems/the-flywheel.md).
- **processes/** — how we do things: [site creation](processes/site-creation.md),
  [content generation and healing](processes/content-generation-and-heal.md),
  [deployment](processes/deployment.md), [the model handoff](processes/model-handoff.md),
  [spotcheck](processes/spotcheck.md).
- **decisions/** — load-bearing calls: [the mission](decisions/the-mission.md),
  [the value model](decisions/the-value-model.md),
  [Doozer vs Doozer+](decisions/two-tier-model.md),
  [the observed world](decisions/the-observed-world.md),
  [implicit verification](decisions/implicit-verification.md),
  [no dark patterns](decisions/no-dark-patterns-ethos.md),
  [icon taste rule](decisions/icon-taste-rule.md),
  [Magellan ID mapping](decisions/magellan-id-mapping.md),
  [the SEO cliff and recovery](decisions/seo-cliff-recovery.md),
  [the Doozer story](decisions/the-doozer-story.md),
  [OKF adoption](decisions/okf-adoption.md).
- **glossary/** — the vocabulary: [Muse](glossary/muse.md), [Cloud](glossary/cloud.md),
  [Family](glossary/family.md), [Niche](glossary/niche.md).

The live **network** (businesses, niches, clouds, families) is emitted as OKF nodes
on demand by `includes/okf-engine.php`, browsed in Admin → Doozer Brain, and
projected outward per-entity as the [Atlas](systems/atlas.md). The whole bundle
exports as one portable `.zip` (Admin → Doozer Brain → Export). No secrets live in
the brain by design.
- [Magellan and the feed pond](systems/magellan-and-feeds.md) — bound external IDs (`business_id_map`) + the gcal/elks_org adapters that make sites living entities.
- [Niche matching and live discovery](systems/niche-matching-and-discovery.md) — a niche page matches OUR tag OR Google's `types`; a niche slug is NOT a place type; the thing you asked for is never junk.
- [Two gates, and the category catalog](systems/two-gates-and-the-catalog.md) — directory visibility vs index eligibility; the promotion step that was never written; `dz nichecatalog`.
- [The photo pipeline](systems/photo-pipeline.md) — references are free, bytes are bought once on first view; the cache key is `md5(name + w/h)` so WIDTH is part of it, and any page can ask "do we already own this?" before spending. `dz imgct`.
- [The Muse v2 layer](systems/the-muse-v2-layer.md) — the v2 render layer never applied to ANY site (token array passed where a muse row was expected); a muse is only as good as its brief. `dz muse do=tokens`.
- [Events to machines](systems/events-to-machines.md) — a live event is only half published until a machine can read it; the schema.org Event pipeline + the timezone rule.
- [The Doozer Score](systems/the-doozer-score.md) — Stick (cost per 100 sites), Tower (value per 100), and the leverage between them.
- [The writer model is chosen on accuracy](decisions/writer-model-and-accuracy.md) — gpt-5.4-mini, decided by an 11-niche bake-off after the pricier model fabricated a fact.
- [The /now surface](systems/the-now-surface.md) — one button, the nearest places, Build-it-on-the-spot; plus the measured teardown of the category page.
- [Doozer Town and the Board Game view](systems/doozer-town.md) — self-building town clouds on doozer.town (slug = the instruction, $0 from inventory, one gated geocode otherwise), ten colour groups, any cloud as a property board.
- [The Chamber Chamber and doozer.world](systems/chamber-chamber.md) — thechamberchamber.com is Directory Harvest's public face (roster read once, dated, sourced; members connected to their Doozer pages; emails never shown); doozer.world is the two-screen front door with thirteen honestly-labelled doors.

---

# Adopt Open Knowledge Format for the Doozer brain

`decisions/okf-adoption.md` · type: Decision · tags: okf, knowledge, decision

> Why Doozer organizes its knowledge as an OKF bundle, scoped to the stable core.

# Adopt Open Knowledge Format for the Doozer brain

**Decision (June 2026):** organize Doozer's internal knowledge as an
[Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog)
bundle (this `knowledge/` directory) and build exploration tools on it.

## Why

- OKF is "just files": markdown + YAML frontmatter in a typed tree, with markdown links
  as graph edges. Vendor-neutral, SDK-free — a perfect fit for Doozer's
  [FTP / no-env model](../processes/deployment.md).
- Doozer already speaks it: the memory topic files are frontmatter-markdown, `docs/` is
  categorized markdown, and `includes/design-md.php` already emits and serves
  OKF-shaped `/design.md` artifacts.
- It gives a durable, linkable "brain" beyond the flat `MEMORY.md` index — usable by
  agents (future Claude) and humans alike.

## Scope and caveats

- Adopt the STABLE core (markdown + frontmatter + links). OKF is v0.1, so do NOT couple
  to Google's Knowledge Catalog ingestion yet.
- The brain is ADDITIVE: `CLAUDE.md` (instructions) and `MEMORY.md` (recall index) keep
  their roles. See [how to grow the brain](../AGENTS.md).
- Engine: `includes/okf-engine.php` parses this bundle, emits the live network as OKF
  nodes ([the flywheel](../systems/the-flywheel.md)'s data), and exports a portable zip.
  Explorer: Admin → Doozer Brain.

## Branding: OKF is plumbing, Doozer Atlas is the product

The outward, AI-native emission layer is branded **Doozer Atlas** (real-world entities
continuously emitting machine-readable knowledge). OKF and schema.org JSON-LD are
*exports* of that layer, not the product. Pairs with the **Doozer Brain** (internal
knowledge) as Brain (inward) vs Atlas (outward). The per-entity public endpoint is
`/.well-known/doozer-atlas` (`format: doozer-atlas/0.1`); see
[the flywheel](../systems/the-flywheel.md).

## Atlas principles (the guardrails)

1. Atlas emits facts, not hallucinations. AI is a CONSUMER of Atlas, not its source.
2. Public information only. Never private contact data.
3. Relationships are first-class objects (CONTAINS, DISCOVERABLE_IN, LOCATED_IN,
   IN_NICHE, NEARBY, COVERS_NICHE/PLACE, HOSTS_EVENT). Density is the long-term moat,
   but only emit edges DERIVED from real data, never invented.
4. Atlas exists automatically for every Doozer host (business, cloud, root, VD).
5. **Every field must justify its existence**: "will this help a human, AI, or another
   Doozer system understand the world around this entity?" If no, do not emit it. Keep
   Atlas small and intentional, not a dumping ground.
6. Provenance is the differentiator: per-field source + an owner-verified tier (tied to
   [implicit verification](implicit-verification.md)) is trust infrastructure nobody can
   copy. Strengthen it over time (confidence, verified_at).

Architecture note: Atlas OWNS THE CONTRACT, not the storage. It is a DERIVED projection
over the source-of-truth tables (always fresh, no migration, no dual-write). OKF /
schema.org / the `/.well-known/doozer-atlas` JSON are exports of the same model. Resist
"Atlas owns a graph DB" and "site renders FROM Atlas" (a rewrite); evolve, don't rebuild.

## Deferred (roadmap)

Public publishing (`/knowledge.md` + a public explorer), a build-in-public marketing
beat, and a self-growing enrichment agent.

---

# Doozer vs Doozer+ (the two-tier model)

`decisions/two-tier-model.md` · type: Decision · tags: direction, strategy, doozer-plus, tiers, product

> The product direction splitting a free templated info page from a paid bespoke full site, and why the split exists.

# Doozer vs Doozer+

Decided July 2026. Doozer splits into two tiers that share the same
[engine and data spine](../systems/architecture.md) but differ in ambition and
price.

## Free — Doozer

A **templated info page** the business owns: generated from Places data + reviews +
enrichment, styled by a [Muse](../glossary/muse.md) drawn from the shared
[family library](../glossary/family.md), assembled from the standard
[section framework](../systems/section-framework.md). The free tier's job is to be
*trusted, working, accurate, tasteful, useful, and good* out of the box, at fleet
scale, for zero cost to the business. This is what 2,400+ live sites run today. The
free-tier rebuild (families/archetypes/structure hardening) is ongoing.

## Paid — Doozer+

A **bespoke full site**: a richer render layer ("Muse+") on the same spine, for
businesses that upgrade via [Stripe billing](../systems/billing.md). Doozer+ is the
"WordPress-killer" ambition: multi-section, distinctive, effectively hand-quality,
without a human building it. It is a **separate render layer**, not a reskin of the
free page, so the free tier can stay lean while Doozer+ goes deep.

## Why split

- **Different jobs.** The free page must be reliable and uniform-good across the
  whole fleet; the paid site must be singular and impressive. Optimizing one layer
  for both compromised both.
- **Honest upgrade path.** The free site is genuinely useful, so the upgrade is a
  real step up, not a crippled-teaser-to-usable jump. No
  [dark patterns](no-dark-patterns-ethos.md).
- **Value capture follows value.** Free creates presence + [leads](../systems/lead-capture.md);
  Doozer+ captures the businesses that want more. See
  [the value model](the-value-model.md).

Shared spine, two render layers. A business is never rebuilt from scratch to move
between tiers: same inputs, richer output.

---

# Icon Taste Rule

`decisions/icon-taste-rule.md` · type: Decision · tags: design, icons, taste

> Primary UI uses crafted SVG icons, never clipart/emoji; color is reserved for state, not for buttons.

# Icon Taste Rule

**Decision (operator, June 2026):** primary and structural UI uses CRAFTED SVG icons
(stylish, on-brand), NEVER default plain emoji or clipart. Emoji is acceptable only as
a playful inline accent (for example the call-intent chips in a popup), not as primary
iconography.

Corollary on the [shared business card](../systems/shared-business-card.md): card
overlay icons are neutral white on the dark translucent circle; **color is reserved for
STATE** (a saved heart is red, a checked-in pin is green), never for the buttons
themselves. Tried colored outlines, reverted — color-as-state reads better and scales
as more actions are added.

Same family of taste calls as [no dark patterns](no-dark-patterns-ethos.md) and
[Design Standards](../systems/design-standards.md): the product should never look cheap
or machine-stamped.

---

# Implicit Verification (no claim system)

`decisions/implicit-verification.md` · type: Decision · tags: auth, ownership, decision

> Business ownership is an email match, not an account/claim flow. Load-bearing platform architecture.

# Implicit Verification

**Decision:** a business owner is recognized by an email match, with no claim system,
no verification queue, no support tickets.

```php
// admin_email is AUTHORITATIVE once set; the scraped email is only the
// implicit owner while UNCLAIMED. (Refined v2.2.396 — see the lock below.)
$isBusinessAdmin = $business->admin_email
    ? ($user->email === $business->admin_email)
    : ($user->email === $business->email);
```

- `businesses.email` — the scraped canonical contact email (public). Grants ownership
  ONLY while the listing is unclaimed (`admin_email` empty).
- `businesses.admin_email` — the claimed / operator-set owner (never shown publicly).
  Set by a redeemed claim code or by Doozer staff. Solves the agency case: one
  `admin_email` across many businesses = one login to manage many.

**The claim lock (v2.2.396).** Originally ownership was `email OR admin_email` — which
let a listing have TWO owners at once: whoever controlled the scraped inbox AND a
different person who redeemed a claim code. Now, once `admin_email` is set the listing
is **locked to that email**: the scraped email no longer grants ownership, and a claim
code from a different email is refused (it cannot take over). The redeemed code records
`redeemed_by_email` (attached to who used it). To CHANGE the email on a claimed listing
the owner must **request a new code**, mailed to the business address (operator voids
the old + clears `admin_email` so the new code can bind). The ownership test lives in
ONE place: `businessIsClaimedByViewer` / `businessOwnerAccount` (`premium.php`); the
redeem guard is in `claimCodeRedeem` (`claim-codes.php`).

Login is the existing magic-link flow (`lb_users` / `currentUser()`); when the email
matches either column, the avatar dropdown reveals "Manage [Business]". Self-healing
email backfill: repeated visits from one fingerprint enqueue a priority scrape that
tries harder to find a contact email, so "I tried last week, it didn't work; today it
does."

A three-tier education affordance (footer utility row, an about-this-listing page, a
soft heuristic nudge) explains the model without nagging. This embodies the
[no dark patterns](no-dark-patterns-ethos.md) ethos and underpins owner-facing parts of
[the flywheel](../systems/the-flywheel.md).

---

# Magellan — cross-service business identity mapping

`decisions/magellan-id-mapping.md` · type: Decision · tags: magellan, identity, ids, architecture, data-driven, marquee, atlas

> Doozer cross-links businesses by verified external IDs (Google Place, Ticketmaster, future services), never by name/city/state. Magellan is the mapping spine. Decided June 2026; not yet built.

# Magellan — cross-service business identity mapping

**Decision:** Doozer is a data-driven ecosystem. A business is cross-linked to any external
service by its **verified ID for that service**, never by matching name + city + state. The
mapping layer is **Magellan**.

## Why

Name/city/state matching guesses ("The Stone Pony" vs "Stone Pony"), produces duplicates, and
breaks silently. Every business already has a Google Place ID; venues built from
[Marquee](../systems/livemusictonight.md) have a Ticketmaster venue ID. The reliable join is
the ID. Magellan makes that the only join.

## What Magellan does

- Maps each Doozer `businesses.id` to its identity in every service it participates in:
  Google Places, Ticketmaster, and any future API/resource.
- Knows the **expected** set of IDs for a business (from its origin/niche) — a Marquee music
  venue must have a TM id; a discovery-built plumber need not — and reports the gaps.
- Surfaces mapping issues: missing-but-expected, conflicts, duplicates, unverified.
- Lets the operator search + tag IDs **without knowing any raw ID** (resolve by
  name → candidate lookup → confirm, or pull from the service's own API).
- Every build path writes its known IDs at creation; the [Atlas](../systems/livemusictonight.md)
  emits them as entity identifiers so AI/agents cross-reference the same way.

## Shape (intended, not built)

A `business_id_map` join table — `(business_id, service, external_id, source, confidence,
verified_at)`, unique per `(business_id, service)`, indexed on `(service, external_id)` for
reverse lookups — generalizing the existing hot columns `businesses.google_place_id` and
`businesses.tm_venue_id`. Backfill from those; then retire the name-match fallback in
`_vdLinkVenues`.

Status: **directive recorded, not built.** See `project_magellan` in the memory layer.

---

# No Dark Patterns (the Doozer ethos)

`decisions/no-dark-patterns-ethos.md` · type: Decision · tags: ethos, principles, decision

> Pure intent, organic, intelligence-driven. The lens for every product and ranking decision.

# No Dark Patterns

The guiding ethos. Doozer is built with **pure intent**: no popups, no deceptive ads or
techniques, no malware, no junk. "Like shopping in the organic aisle." Intelligence is
used to find the diamonds in the rough and the needles in the haystack, and to serve the
two parties that matter: the customers and the businesses.

This is the lens for product AND ranking decisions, not a single feature. It connects to:

- [Implicit verification](implicit-verification.md) — no account walls, no claim
  upsell; we recognize owners and help them.
- [Design Standards](../systems/design-standards.md) — tasteful, readable, never
  AI-slop; fix root causes, not bandaids.
- The mission: a *new web* in the GeoCities spirit, safe and useful, every site its own.

Sibling rules with the same spirit: the [icon taste rule](icon-taste-rule.md), and "no
em dashes in user-visible copy" (so it does not read as machine-written).

---

# The Cloud Fabric — automatic clouds over the observed network

`decisions/the-cloud-fabric.md` · type: Decision · tags: clouds, strategy, network, observed-world, real-estate, awesomeaddresses, seo, magellan, thanksio, cross-linking

> The connective-tissue vision. Once every local business exists as a Doozer site (observed / painted on demand), Clouds form automatically around topic, area, and non-obvious relationships, cross-linked into one self-reinforcing, claimable, quality-indexed network that serves both businesses and users.

# The Cloud Fabric

**Captured with Jon, Jul 7 2026.** The strategic thesis that ties the whole
network together. Read alongside [the observed world](the-observed-world.md),
[the value model](the-value-model.md), [SEO cliff](seo-cliff-recovery.md), and
[the mission](the-mission.md).

## The mental model
Assume every local business already has a Doozer site — "painted on demand" the
moment it is observed (the observed-world pivot). Once that is the substrate,
the interesting value is not the individual page, it is the **relationships
between pages**. Those relationships are Clouds.

Clouds form around three axes, and the third is the differentiator:
1. **Topic** — food, bowling, golf, arcades, garden centers, specialty markets,
   farmers markets, churches, shopping, services. Any niche, any grain.
2. **Area** — a town, a county, a region.
3. **Non-obvious connection** — the businesses you need *together* for a real
   task, even across niches. This is the insight.

## The load-bearing example: the real-estate transaction
The businesses you touch buying a home are not one niche — they are a workflow:
realtor's office → real estate lawyer → title / escrow agency → town hall →
county clerk → local post office → and "where do we eat lunch in our new town."
That bundle is a Cloud. Two shapes:
- **Personal** — a buyer's private bookmark cloud for *their* transaction (My
  Doozer Cloud over saved businesses), later including their favorite
  AwesomeAddresses homes.
- **Public niche cloud** — a "moving to <Town>" / "real estate in <Town>" cloud
  any agent or buyer can use, per city/town, indexable and shareable.

Real estate is also the **wedge for monetization**: realtors are heavy direct-
mail buyers, so they are prime [Thanks.io](../systems/thanksio.md) claim / Doozer+
targets *now* (record_types onlybusinesses in a farm area, mail them their site).
When [AwesomeAddresses](../../project_new_roots_roadmap.md) (Zillow-style
residential root) launches, the whole real-estate professional graph — listing
agents, buyer agents, developers, brokers, title, lenders — becomes a first-class
audience working inside that system.

## Why this points at success (the compounding)
- **Self-building** — observed-world paints the sites; Prospector / Cloud Factory
  / Thanks.io radius buys grow inventory without hand work.
- **Clustered** — [Cloud Factory](../../project_prospector_status.md) already does
  niche+region → cloud + sweep + build + auto-join. Automatic + recipe-driven
  clouds are the next grain (multi-niche bundles like the transaction cloud).
- **Cross-linked** — clouds are genuine internal links between real pages;
  [Magellan](../../project_magellan.md) makes every cross-link by stable ID, and
  domains/subdomains/roots interlink through shared chrome (network footer,
  neighborhood rails). Link equity flows where value is.
- **Claimable + valuable** — every node is a pre-built lead; the CLAIM→EARN funnel
  turns attention into owners and Doozer+ revenue. Clouds raise the value of each
  node (context, referrals, "more from the neighborhood").
- **Indexed** — clouds are legitimately useful, curated pages that deserve to rank.

## The guardrails that decide success vs. cliff (do NOT skip)
1. **Quality-gated indexing.** The [SEO cliff](seo-cliff-recovery.md) proved that
   mass-indexing thin, templated, scaled pages gets the WHOLE network
   algorithmically demoted. "Indexed by search engines" must mean: index only
   observed + enriched + genuinely useful pages and clouds; keep thin/unclaimed
   inventory out of sitemaps until it earns its place. Clouds help (curation +
   real internal links) ONLY if the clouds themselves are substantive, not empty
   niche×town permutations. Auto-cloud generation must have a content floor.
2. **No slop, taste first** ([the mission](the-mission.md)) — a cloud with three
   junk members is worse than no cloud. Curation and a membership quality bar.
3. **Cost discipline** ([[feedback_api_costs]]) — painting-on-demand and radius
   buys spend money; gate behind observation/demand, reuse cache, never mass-build
   speculatively.
4. **Ethics** ([[project_doozer_ethos]]) — organic value, no dark patterns; mail
   and clouds must serve the business and the user, not trick either.

## Packs — the multi-niche recipe (SHIPPED v2.2.375)
A **pack** is a curated set of Google Places types that belong together by
PURPOSE. `includes/cloud-packs.php` ships **31 hand-authored packs** referencing
real `niche_mappings.google_type` values, each with a **quality gate**
(`min_reviews`, `require_location`, `min_rating`, and `open_247` for the 24/7
pack) and, where a single pro naturally owns the workflow, **keeper-eligibility**.
Examples: Real Estate Transaction, Wedding, New in Town, Movers & Relocation,
Home Improvement, Money & Advice, Final Arrangements (the 7 keeper packs), plus
Me Time, Med Near Me, Lessons & Growth, Get Moving, New Parent, Pet Parent, Where
to Eat, Coffee & Cowork, Sweet Tooth, Farm to Table, Date Night, Family Fun, Night
Out, Fresh Air, On the Water, Green Thumb, Two Wheels, Local Boutique, Auto Care,
Ink & Adornment, Smoke & Vape, Culture & Books, Faith & Worship, Open 24 Hours.
`cloudPackPreview($key,$city,$state)` is a COST-FREE count of existing Doozer
members a pack would gather in a town, with a `meets_floor` flag — the content-
floor guard so we never build/index a thin pack cloud. Ops: `dz packs
list|keeper|show|preview|build|keeper_offer|set_keeper`. Membership = niche-set +
geo — the cloud engine already supports it (cloud_type=niche, `criteria.niches`
is an array). **`cloudPackBuild($pack,$city,$state)` SHIPPED**: creates the live
cloud at `<pack>-<city>-<st>.doozer.cloud`, cost-free from existing members,
idempotent, `fill=1` sweeps below-floor niches (`prDiscoverySweep`, confirm-gated).
Proven end to end (Real Estate / West Milford NJ, live). WATCH-ITEM: raise the
content floor above the demo's 4 and gate INDEXING (sitemap) on a higher quality
bar than build, so thin pack clouds render but do not get indexed until filled.

## The Keeper — territorial exclusivity as the sale
Keeper-eligible packs are monetized by **exclusivity**: one professional becomes
the KEEPER of that pack cloud in their city/region — their name, brand, and
referral position across the whole workflow bundle. One keeper per pack per
territory. The pitch (esp. realtors): "Own the Real Estate cloud for your
territory. Every buyer and seller in town sees you first." Offer it to every pro
in the farm area (Thanks.io radius, `record_types=onlybusinesses` + real estate
niche); one takes it. Mechanically the Keeper = the cloud's paid sponsor/owner
(clouds already carry `sponsor_name/url/logo` via `cloudSetSponsor`, and the
observed-world claim path grants ownership) sold as a Doozer+/premium position.
Any realtor pitch MUST lead with claiming their territory — scarcity closes it.

## Sequence (how we get there)
- NOW: multi-niche **cloud recipes** (transaction cloud as the first) on the
  existing cloud engine + Cloud Factory; Thanks.io realtor claim campaigns; keep
  indexing quality-gated.
- NEXT: automatic cloud formation (topic/area detection over observed businesses),
  personal transaction clouds in My Doozer Cloud.
- LATER: AwesomeAddresses homes as cloud members + the full real-estate pro graph.

The direction is sound because the machinery mostly exists (observed-world, cloud
engine, Cloud Factory, Magellan, claim funnel, Thanks.io). The work is composing
it into recipes + automatic formation, and holding the quality/indexing line so
the network compounds instead of cliffs.

---

# The Doozer Story

`decisions/the-doozer-story.md` · type: Decision · tags: story, brand, mission, canon

> The canonical WHO/WHAT/WHEN/WHERE/WHY/HOW of Doozer, distilled. The single source the Postcards, landing copy, and press materials draw from.

# The Doozer Story

The distilled canon, written from June 2026. The expressive, evolving form is
[Postcards](../systems/postcards.md); this is the spine they hang on.

**WHO.** Doozer is a BerrySmart product, built by one person: Jon Berry, solo founder and
operator, hands-on and technical. Named after the tireless little builders from Fraggle
Rock. Each Cloud has a Keeper; NearByMeNow carries the traffic; the Muse gives each site
its voice.

**WHAT.** A domain-driven platform that turns unused or forwarded domains into useful,
findable single-page websites for real local businesses. One codebase, 2,400+ live sites,
every one different. Around it: NearByMeNow (directory), Doozer.Cloud (curated Clouds),
my.doozer.cloud (a person's saved places), and the coming GoHe.re (gatherings) and wuz.ai
(memory) layers. LeadBuilder is a sibling. [Atlas](okf-adoption.md) makes it all legible
to machines.

**WHEN.** Built around turning fifty, as a swan-song legacy project, in the margins of a
full life: early mornings and late nights, between full-time work and raising a family.
And built for a specific moment in history: when AI answer engines replaced the page of
blue links and the question changed from "what ranks" to "what gets cited."

**WHERE.** Bootstrapped, no outside funding, answerable only to its users and its
standards. Deployed by hand from a laptop. No office, no team. The whole company fits in
a backpack.

**WHY.** Local businesses should not lose to AI; they should be found by it. The bakery,
the welder, the tailor, the lodge hold neighborhoods together and are exactly the ones
least likely to have a current site, which now means least likely to be found. Doozer
points the same AI the other way. The bar is taste: useful, accurate, tasteful, no dead
ends, no AI-slop, no dark patterns. "We Build Because."

**HOW.** One codebase; the site is built on the fly from the domain used to reach it.
Six steps, most under a minute: point, find (Google Places), enrich, match (a
[Muse](../systems/muse-system.md) from a library of 100), compose (AI prose plus a guarded
layout pipeline), improve (every signal feeds back). Written to self-heal, because usually
no one is watching.

**VISION.** Seven surfaces, one universe, a [flywheel](../systems/the-flywheel.md) where
each layer makes the next more valuable. The real, local, physical world deserves a web
that is on its side. Built one careful piece at a time.

---

# The Mission (the north-star quality bar)

`decisions/the-mission.md` · type: Decision · tags: mission, quality, north-star, values, why

> Why Doozer exists and the non-negotiable quality bar every site must clear — the lens for every build decision.

# The Mission

Doozer is BerrySmart's (Jon Berry's) legacy work: a serious attempt to give every
small business a web presence that is genuinely good, generated automatically, at a
scale and price no human-built approach can match. It is built with pure intent,
not extraction. The tagline is **"We Build Because."** (never invent others).

## The quality bar (non-negotiable)

A site ships only if it is all of:

- **Trusted** — accurate, honest, no misinformation. See [fact-guard](../systems/fact-guard.md).
- **Working** — fast, live, no broken states, no [dead ends](../systems/lead-capture.md).
- **Accurate** — reflects the real business, [GIGO](../systems/location-data-enrichment.md).
- **Tasteful** — distinctive and dignified, never templated slop. See the
  [Muse system](../systems/muse-system.md) and [design standards](../systems/design-standards.md).
- **Useful** — answers the visitor's real question.
- **Good** — the kind of thing you would be proud to have your name on.

"No dead ends, no slop" is the shorthand. A page that looks AI-generated (em-dashes,
banned phrases, generic layout) fails "tasteful." A lead with nowhere to go fails
"working." A wrong lodge number fails "trusted."

## The ethos

No dark patterns, ever ([the ethos](no-dark-patterns-ethos.md)). The intelligence
of the platform is aimed at finding the diamonds and needles for real people, a new
way to think about the web: discovery and presence in service of the visitor and
the business, not manipulation. [Value](the-value-model.md) is captured honestly,
downstream of value genuinely created.

This is the lens. When a build decision is ambiguous, choose the option that keeps
the site trusted, working, accurate, tasteful, useful, and good. Read this before
building.

---

# The Observed World (render-on-demand + the lead flywheel)

`decisions/the-observed-world.md` · type: Decision · tags: strategy, observed-world, quantum, lead-flywheel, revenue, seo, why

> Doozer already contains every local business in spirit; a site or cloud becomes real only when observed. Each materialized site is simultaneously inventory and a pre-built lead. This reframes what to build, what to measure, and how money is made.

# The Observed World

## The premise

Because of the [More Places Nearby](../systems/network-growth.md) expansion, the
Doozer ecosystem creeps outward to include every nearby business, especially
within niche: restaurants, laundromats, real estate offices, gas stations, all
of it. View this as a live-painted world, the way an RPG renders only the pixels
the player can see while a whole world of data stands behind them. **Doozer, in
spirit, already holds a directory of every local business.** A site — or a
[cloud](../systems/clouds.md) — exists the moment it is observed. It is, in a
way, already there.

The engine for this already exists: `resolvePlaceToEcosystem`
([place-resolver](../systems/network-growth.md)) is skeleton-first,
materialize-on-observe, defer-enrichment. The shift is mental: **stop treating
"sites we have built" as the inventory.** The inventory is every business that
*could* be rendered; built sites are just the observed, cached subset.

## The two assets in one act

Every business More Places Nearby materializes is at once:

1. **Inventory** — a pixel in the observed world (coverage, SEO surface, and the
   substrate AI agents can query).
2. **A pre-built lead** — a finished, live product sitting on a real business's
   doorstep that they have never seen.

Concept one manufactures the inventory. Concept two monetizes each unit. Revenue
from the sales funds the enrichment that makes the inventory real. It is a
self-funding loop (see the flywheel diagram this node was captured from).

## The two gates (load-bearing)

Infinite coverage has two failure modes; one rule closes each.

- **The observation / SEO gate.** Doozer already took an algorithmic
  scaled-content demotion once ([SEO cliff](seo-cliff-recovery.md)). NEVER
  pre-render the latent world into crawlable pages. A page becomes real
  (indexable, enriched) only when a genuine observer earns it. Done this way the
  observed-world model is the *antidote* to the cliff, not a bigger version of
  it — every indexed page has a real reason to exist.
- **The pride / quality gate.** A latent world of thin skeletons is worthless and
  would embarrass the brand, against the [mission](the-mission.md) and the
  [no-dark-patterns ethos](no-dark-patterns-ethos.md). The observation itself
  triggers enrichment proportional to interest, and no site is mailed or pitched
  until it passes a quality bar. The two gates force the machine into quality.

## The lead flywheel (how the money starts)

Doozer inverts the SaaS funnel: **the site is live before the pitch.** Not "sign
up and we will build it," but "this is your website, it is already working, claim
it." Mostly assembly, because the pieces exist:

- **The email is the login.** [Implicit verification](implicit-verification.md)
  says `user.email === business.email` → business admin, no claim system. So
  every [lead-routing email](../systems/lead-capture.md) Doozer already sends is
  the pitch: log in with this address to claim the listing and manage leads.
- **QR + direct mail.** The [Places address is on file](../systems/prospector.md);
  Thanks.io mails a postcard with a QR to the live site. Instant gratification:
  scan, see it, sold.
- **Billing is wired.** Upgrade to [Doozer+](two-tier-model.md) at $99/yr via
  [Stripe billing](../systems/billing.md).

Economics work at trivial conversion: a postcard is well under a dollar; $99/yr
needs ~1–2% to be strongly positive, and the lead-email funnel is nearly free.
Every new site from More Places Nearby drops a fresh prospect into the funnel —
which is exactly what the new-site ping already fires on.

## What changes

- **Metrics.** Stop counting sites built (vanity, and past a point an SEO
  liability). Count **observations served** and **businesses claimed**.
- **Product identity.** Doozer is not a website builder; it is the rendering
  engine for the local commercial world.
- **Tools this implies** (not yet built): a unified **Demand Ledger** (fold
  `search_demand` + `lmt_interest` + GSC impressions + nearby-taps + name-404s)
  that decides what to paint next and what is safe to index; **build-on-query**
  as the front door; **cloud-on-demand** criteria directories; a **reality
  meter** per entity (how much real signal backs this pixel, gating index and
  mail); and the [Atlas](../systems/atlas.md) extended into a queryable / MCP
  surface so AI agents — the new observers — can ask "does Doozer have X?" and
  always get "yes, here it is." Being the local layer AI agents cite and route
  through may be a bigger business than the sites.
- **Revenue paths:** claim/upgrade on materialized sites; affiliate
  [money clouds](../systems/money-clouds.md) conjured on demand; API/agent access
  to the queryable world; the demand ledger itself as market intelligence.

## Build order (cheapest-first)

1. **Step 0 — claim CTA in lead emails. SHIPPED v2.2.355.** `nbmnLeadEmailBusiness()`
   appends a "claim your listing" block (their live site + the email-is-the-key
   pitch); the [claim landing](../systems/lead-capture.md) at `?page=claim&b=<id>`
   (`includes/claim-listing.php`) shows the owner their live site and offers a
   one-step magic-link login (existing `claim` purpose + `?ajax=magic_link_send`),
   with a soft Doozer+ $99/yr upsell.
   **The CLAIM to EARN half, shipped v2.2.371.** A claim now delivers real value
   and the upgrade actually bills. (a) Enrichment surfaces the scraped
   `contact_email` onto the empty `businesses.email` column
   (`performBasicEnrichment`) — without this, an emailless-but-scraped business
   could never match on implicit verification, so no re-scan could ever unlock a
   claim. (b) `?page=plus` (`includes/plus-landing.php`) is a real Doozer+ page
   telling the two value stories and starting Stripe checkout (sku
   `premium_yearly`, $99/yr, code `LAUNCH20`); previously it dead-ended on the
   landing page. (c) Ads come off a page the moment it is claimed (an account
   exists for the contact/admin email) or when the owner is viewing —
   `businessAdsSuppressed()` + friends in `premium.php`, hooked into
   `business-site.php`'s `$showAdsense`. Ads are the monetization of UNCLAIMED
   inventory only. (d) `includes/manage-business.php` (`?page=manage&b=<id>`,
   owner-gated) is the "Manage your site and leads" surface — ad-free
   confirmation, editable phone/website, leads inbox. (e) The claim page's
   non-match state has "I added it, re-scan my site" → `?ajax=claim_rescan`
   (priority `basic_enrichment`, rate-limited) so an owner can add their email
   and unlock the claim. Still NO claims table — "claimed" is derived from an
   `lb_users` email match, exactly the implicit-verification model.
2. **Step 1 — QR + claim/upgrade landing** on top of existing Doozer+ / Stripe.
3. **Step 2 — Thanks.io direct mail**, gated on the reality meter (only mail a
   site worth pride).
4. **Alongside — the Demand Ledger**, the brain that says what to materialize and
   what is safe to index.

Captured July 5, 2026 (with Jon) as a strategic pivot to examine for value and
revenue. **Realized by [The Growth Engine](../systems/growth-engine.md)** — the
concrete observe→serve→convert→learn machine (postcards, offers, Hot-Leads CRM).
Related: [the value model](the-value-model.md),
[two-tier model](two-tier-model.md), [SEO cliff](seo-cliff-recovery.md),
[no dark patterns](no-dark-patterns-ethos.md).

---

# The SEO Cliff and Recovery (why we noindex until quality-gated)

`decisions/seo-cliff-recovery.md` · type: Decision · tags: seo, quality, elks, scaled-content, noindex, lesson

> The May 2026 elks.club algorithmic demotion for scaled thin content, and the enrich-then-index discipline it burned into the platform.

# The SEO Cliff and Recovery

Around **May 22-24 2026** the elks.club fleet fell off a search cliff. Diagnosis
(load-bearing): it was an **algorithmic scaled-content demotion**, NOT a manual
penalty, a 521 outage, or a deindex. westmilford.biz, on the same infrastructure,
held steady as the control — so the cause was content, not hosting. Google
demoted a large set of similar, thin, auto-generated pages.

## The lesson (now a platform rule)

A flood of near-identical, thin sites is a liability, not growth. Every generation
and growth decision inherits this:

- **Enrich then index.** On-demand builds (see [network growth](../systems/network-growth.md))
  ship as a skeleton that is **`noindex` until it is enriched AND passes a quality
  gate**, then flips to index + sitemap. Never dump bare skeletons into the index.
- **Variety and voice.** Thin sites are fixed with BETTER DATA and real
  [enrichment](../systems/location-data-enrichment.md), not tone. The
  [Muse system](../systems/muse-system.md) + families exist so sites don't read as
  templated slop.
- **Quality gate has teeth.** [Regeneration](../systems/regeneration.md) is
  quality-gated with rollback; the "More places nearby" build bar keeps genuinely
  thin places out.
- **Dedup + accuracy.** [Fact-Guard](../systems/fact-guard.md) and canonical
  handling keep pages distinct and correct.

Recovery is content quality + dedup + a quality-gated index, not a hosting fix.
This is why "make it work fast" is always balanced against "no slop, no dead ends"
([the mission](the-mission.md)) — the cliff is the proof of what happens when it
isn't.

---

# The Value Model (how Doozer creates and captures value)

`decisions/the-value-model.md` · type: Decision · tags: value, monetization, economics, strategy, why

> Where Doozer's value comes from and how it is captured, so an AI understands the economic engine, not just the software.

# The Value Model

Doozer's premise: **most small businesses have no good web presence, and the
market has failed to give them one cheaply, quickly, and tastefully.** Doozer
generates one automatically from data the business already generates (Google
Places, reviews, photos, its own activity). The value is created before anyone
pays, and captured along several honest paths, never with
[dark patterns](no-dark-patterns-ethos.md).

## Value created (before any charge)

- A real, useful, accurate single-page site exists at a memorable domain, often
  better than the business's outdated official site or its absence.
- The business becomes discoverable across the [network](../systems/the-network.md):
  NearByMeNow, Clouds, vertical directories, search.
- Visitors get a fast, trustworthy answer to "is this place open / good / near me,"
  which is the whole [ethos](../systems/the-flywheel.md).

## Value captured (how money is made)

1. **Lead generation** — the primary engine. Every call, message, and intent
   signal is [captured and routed](../systems/lead-capture.md) to the business
   through LeadBuilder. Qualified leads are the product a business will pay for
   because they map directly to revenue.
2. **Ownership / Doozer+** — a business [claims its site](implicit-verification.md)
   for free and can upgrade to the paid [Doozer+ tier](two-tier-model.md): a
   bespoke full site, priced via [Stripe billing](../systems/billing.md).
3. **Premium features** — analytics, custom branding, agency multi-site
   management, higher new-site caps.
4. **Sponsorship / advertising** — tasteful, network-level placements
   (`renderDoozerSponsorBar()`), never spammy.
5. **Affiliate / referral** — booking, ticketing, and commerce CTAs where they
   genuinely help the visitor (e.g. tickets on [live music](../systems/livemusictonight.md),
   vetted "Shop Now" links).

## Why this compounds

Each captured signal makes the next site cheaper to justify and better targeted
(via [Prospector](../systems/prospector.md) and enrichment). Value capture funds
value creation, which produces more signal. That is [the flywheel](../systems/the-flywheel.md);
this node is its economics. The north star is never extraction: a site must be
trusted, working, accurate, tasteful, useful, and good, with no dead ends and no
slop, or it is not shipped.

---

# The writer model is chosen on accuracy, not price

`decisions/writer-model-and-accuracy.md` · type: Decision · tags: ai, models, accuracy, cost, content-generation, decision

> GPT-5.4 mini writes Doozer's business copy. Decided 2026-08-22 by an 11-niche counterbalanced bake-off in which the more expensive model fabricated a verifiable fact on a live page.

# The writer model is chosen on accuracy, not price

**Decision:** `business_website` content generation runs on **`gpt-5.4-mini`**.

## The test

Eleven real businesses, one per niche, each regenerated by both candidates against
identical source data. **Counterbalanced**: a regeneration feeds the previous copy back
into the prompt, so whichever model runs second sees the other's work. Five businesses
saw Sonnet first, six saw GPT first, cancelling the order effect. Every cost read
per-call from `ai_generation_history`, never estimated from a price sheet.

| | claude-sonnet-4-6 | gpt-5.4-mini |
|---|---|---|
| per site | $0.03400 | **$0.00748** |
| **per 100 sites** | $3.40 | **$0.75** |
| seconds | 28.7 | **6.5** |
| output tokens | 1,335 | 973 |

Cheaper and faster on **every single business**, low variance in both arms.

## What actually decided it

Cost did not. $2.65 per hundred sites does not justify changing the voice of the network.

Writing for **Thessen Services**, a plumber with no website on file and no cached source,
Sonnet opened: *"Permit files across three county offices carry the record."* The strings
`permit` and `county` appear **zero times** in that business's entire stored record. The
sentence is fluent, specific, checkable, and invented. Given identical inputs,
gpt-5.4-mini opened with the verified street address, and across all eleven specimens it
stayed inside known facts.

**A wrong fact is worse than a missing one.** That is the platform's whole premise for
[fact-guard](../systems/fact-guard.md) and the geo-anchor rule, and it settled a decision
the cost data alone would not have.

## Honest limits

One observed fabrication in eleven pairs is not a measured hallucination rate. It was
enough to act on because the failure mode is severe and the cheaper model carried no
offsetting cost, not because eleven samples prove a rate.

**What we gave up:** Sonnet writes better headlines and ~27% more copy. A split where the
cheap model writes the body and a stronger one writes the headline is worth testing.

## How to re-run this

`dz aimodelreg repoint=business_website model=<id> live=1` swaps the writer;
`dz aimodelreg sync=1 live=1` refreshes the priced catalog. **Before switching to any
more verbose model, check its output length against the pipeline's max-tokens cap** —
Sonnet 5 blew past 2,048 tokens, the JSON truncated, and it silently blanked two live
pages before `_regenContentIsSubstantive()` existed to catch it.

Case study (all 22 generations readable side by side):
https://claude.ai/code/artifact/a9ac8ae5-3bc9-477f-b731-b99950afe488

Related: [[magellan-and-feeds]], [[the-doozer-score]], [[fact-guard]].

---

# AI Discoverability — being the local layer AI agents cite

`systems/ai-discoverability.md` · type: System · tags: ai, seo, discoverability, llms-txt, robots, duckduckgo, bing, indexnow, atlas, agents

> How Doozer makes itself legible to AI agents and AI-powered search (ChatGPT, Perplexity, Claude, Gemini, Copilot, DuckDuckGo). The DDG=Bing insight, the explicit crawler welcome, llms.txt, and the machine-readable stack (schema.org JSON-LD + Atlas + design.md) that already existed.

# AI Discoverability — being the local layer AI agents cite

The [[the-observed-world]] endgame is that AI agents are the new observers: they ask "is there a
good X near me?" and Doozer should always be able to answer "yes, here it is, with structured
data." This node is how a Doozer host makes itself legible to AI and AI-powered search.

## The load-bearing insight: DuckDuckGo is Bing

DuckDuckGo does **not** run its own web index. Its results are overwhelmingly **Bing-sourced**
(plus DuckDuckBot for a little, Apple Maps for local). So "optimize for DuckDuckGo" mostly means
"be in Bing" — which the existing **[[indexnow]]** pipe already does (instant Bing submission on
publish; verified healthy, HTTP 202s flowing). Same logic extends to the AI assistants: ChatGPT
search, Copilot, and DuckAssist all lean on Bing's index. **IndexNow is therefore the single
biggest AI-search lever, and it was already built.** The gap was the AI-native layer below.

## The machine-readable stack (most of it pre-existed)

Every Doozer business page already emits, and now advertises, structured data:
- **schema.org LocalBusiness JSON-LD** (`schema-org.php`) — type-specific
  (FastFoodRestaurant, PostalAddress, GeoCoordinates, AggregateRating, opening hours). The single
  richest signal for both rich results and AI extraction.
- **The [[atlas]] feed** at `/.well-known/doozer-atlas` (JSON) — the per-entity outward emission,
  linked in `<head>` via `<link rel="alternate" type="application/json">`.
- **`/design.md`** (Google-Labs DESIGN.md) — agent-facing brand spec, `<link rel="alternate"
  type="text/markdown">` in head, `X-Robots-Tag: noindex`.
- **Sitemaps** + robots.txt served dynamically per domain.

## What v2.2.420 added

- **`/llms.txt`** (`includes/llms-txt.php`, the llmstxt.org convention) — a concise Markdown card
  served per domain that hands an agent a plain-language "here's what this is" plus links to all of
  the above. Four shapes: `master` (the network), `nbmn` (the directory), `root` (a directory root
  like elks.club/westmilford.biz — `isRootWildcardDomain()`, reads its `root_domains` name/tagline/
  description), and `business` (pulls real name/niche/city from `domains`→`businesses`, generic
  fallback if unresolved). Routed in `index.php` next to robots/design.md, in the sessionless +
  drain-skip fast paths. **Gotcha caught v2.2.421:** roots front many businesses, so they must NOT
  fall through the `business` branch (it would mislabel a whole directory as one local business).
- **Explicit AI-crawler welcome in robots.txt** — `doozerAiCrawlers()` (GPTBot, OAI-SearchBot,
  ChatGPT-User, ClaudeBot, Claude-SearchBot, PerplexityBot, Google-Extended, Applebot, Bingbot,
  DuckDuckBot, DuckAssistBot, Amazonbot, Meta-ExternalAgent, CCBot, …). `User-agent: *` already
  allowed them; naming them is an intentional "yes, index us" (most of the web blocks these).
  Emitted only on business + NBMN (no Disallows to bypass); the master keeps `*` authoritative so
  its admin Disallows bind every bot, and just advertises the llms.txt summary line.

## The stance (a choice, not a default)

Most sites are racing to **block** AI crawlers. Doozer does the opposite: the whole model is that
every observed business should be findable, and being the structured local layer AI agents cite and
route through may be a bigger business than the sites themselves ([[the-observed-world]]). So we
roll out the welcome mat and hand agents citable data. Guardrail: the data must be **true and
current** before we invite citation — the quality gate ([[the-mission]]) still governs what gets
indexed and enriched; we never advertise a thin skeleton.

## Verify by looking (not the token)
`curl` the live surfaces on a real host: `/llms.txt`, `/robots.txt`, `/.well-known/doozer-atlas`,
and grep the homepage for `application/ld+json`. Per [[feedback_verify_by_looking]], confirm on
live pixels/bytes, since a legacy path can silently no-op.

Related: [[indexnow]] (Bing pipe), [[atlas]] (outward emission), [[the-observed-world]] (agents as
observers), [[seo-cliff-recovery]] (only index what is real).

## The brain itself, for agents (Sept 2026)

The whole knowledge bundle is live at four addresses on doozer.work, rendered from the
`knowledge/` files on every request (15-minute cache), so deploying a node updates it:

| form | address | robots |
|---|---|---|
| **HTML index, start here** | `https://doozer.work/brain.html` | none; ~30KB, one link per node |
| HTML, one page per node | `https://doozer.work/brain/<node>.html` | none; ~5 to 20KB each, cross-links rewritten, prev/next |
| HTML, everything on one page | `https://doozer.work/brain-full.html` | none; ~420KB, for readers that can take it |
| Markdown | `/brain` | noindex, noarchive (shareable, not a search result) |
| plain text | `/brain.txt` | noindex |
| JSON | `/brain.json` | noindex |

The HTML forms exist because most agent fetchers choke on `text/markdown`, on an
extensionless URL, on a single 450KB document, or treat noindex as "do not read". The
server was never at fault (every form returned the right type); the SHAPE was. Give an
agent the index and it can walk the pages it needs. The operator's rule: it is not
open source, and it is not the secret recipe; hand the `.html` address to an agent and
it reads everything, with a table of contents.

---

# AI Postcards — Factory, Theme Studio, and Campaigns

`systems/ai-postcards.md` · type: System · tags: postcards, factory, theme-studio, campaigns, claim, thanksio, direct-mail, vision-gate, audience, ai-image

> How Doozer builds and mails postcards. The Factory assembles accurate cards deterministically (real screenshot/code/QR, no per-card AI cost); the AI Theme Studio designs vision-gated backgrounds; campaigns give each card a purpose + audience. Claim Your Site is the first campaign.

# AI Postcards + the Postcard Factory

> **v2.2.381 — the Factory is now the default, accurate path.** Generating the
> whole card with AI every time was expensive AND inaccurate (it approximated the
> claim code, invented the short URL, hallucinated the domain in the screenshot's
> address bar). The **Postcard Factory** (`includes/postcard-factory.php`) flips
> it: AI designs backgrounds/layouts; every ACTUAL card is composed
> deterministically from REAL values and flattened to a print-ready 1800x1200 PNG
> via **Cloudflare Browser Rendering** (`postcardFactoryFlatten` posts `html` +
> viewport to the same CF endpoint used for screenshots). Real screenshot in a
> browser chrome showing the ACTUAL Doozer domain, real minted claim code, real
> doozer.link short URL, a real scannable QR, real logo, Machi copy. No per-card AI
> cost, nothing to proofread. `postcardFactoryCompose($bizId,$opts)` files it in
> the same review queue (no gate — values are deterministic). Themes: paper / navy.
> Admin: AI Postcards page "Build card" panel; Machi defaults to engine='factory'.

> **v2.2.395 — Multi-campaign system.** Postcards are no longer claim-only. A
> **campaign** = purpose + audience + copy/offer (`postcard_campaigns`,
> `includes/postcard-campaigns.php`). The seeded, locked **claim_your_site**
> campaign (audience=unclaimed) IS the old behavior, now named. `campaign` already
> threaded through postcard_designs/thanksio_sends/claim_codes/machi_touches as a
> string; this formalizes the keys. **Audiences** (hard-coded): all / unclaimed
> (admin_email empty) / claimed (admin_email set) / premium (owner lb_user on a
> premium-class tier), each + niche (primary_niche) + location (state_code/city);
> base = live Doozer site + mailable address. `postcardCampaignAudienceWhere/Count/
> Sample`. **Purpose seeds generation:** `postcardCampaignThemePromptSeed` feeds the
> Theme Studio background prompt (`postcardThemeCreate` opt `campaign`);
> `postcardCampaignMessage` resolves the BACK copy (claim → warm/leads/curious
> variants; else message_template with tokens {name}{city}{code}{offer_code}
> {short_url}). Admin: Campaigns cards + Create/Edit modal (live audience count),
> Campaign selector on Factory + Studio, review queue pagination + finder (search +
> campaign filter). `dz campaign do=list|show|save`. NEXT phase: campaign-specific
> FRONT copy + audience-gated sending (don't mail a claimed business a claim card).

> **v2.2.392 — Two-score gate + back copy + Thanks.io levers.** The theme gate
> (`postcardThemeGateCard`) now returns TWO independent real scores because there is
> no in-admin way to tweak a theme after the fact: **accuracy** (every required
> element present, uncropped, legible — an explicit checklist is passed to the vision
> model) and **design** (aesthetics + theme match only). A theme goes ACTIVE only
> when BOTH clear `postcard_theme.gate_pass_score` (default 78); else `draft`. Stored
> in `postcard_themes.design_score` / `accuracy_score` (idempotent ALTER); shown in
> the Studio result and gallery tiles. Card template fix: `.left` scrim is full grid
> height (`grid-template-rows:100%`) so the "doozer." wordmark is always inside the
> legibility box. **Back-of-card copy** (`thanksIoClaimMessageVariants` in
> `thanksio.php`): warm / leads / curious variants, personalized with the town + a
> typed `doozer.link` fallback, no em-dashes. **Thanks.io best practice:** the FRONT
> is the Factory image; the BACK is `message` + `qrcode_url` + return address. Turn
> on `thanksio.handwriting_style_id` (handwritten back lifts response), set
> `thanksio.size` (4x6 default; 6x9/6x11 stand out but need a matching front aspect),
> `thanksio.message_variant`. Rules that win direct mail: personal, specific to them,
> ONE clear action (scan), readable in three seconds, honest, warmly signed.

> **v2.2.390 — AI Theme Studio.** The untrustworthy full-AI card painter is
> repurposed into a theme/background designer only; the Factory still does the real
> assembly. `includes/postcard-theme-ai.php`: `postcardThemeCreate($style,$opts)`
> takes a plain-words style ("beach vacation", "Thanksgiving warm orange",
> "Bronx blue/white pinstripe") + optional `business_id`/`match_site` for niche
> style cues, builds a LAYOUT-AWARE background prompt (calm left third for text,
> calm bottom-right ~1.5in for QR, right half supporting the screenshot, NO
> text/letters/logos), generates the bg via the configured image provider
> (`postcardAiImageProvider`), then a **vision pass reads the palette BACK from the
> pixels** (`postcardThemeReviewBg` → accent/ink/dark, reads `raw_text`). It then
> composes a REAL sample card (live screenshot + demo code/QR) and runs a second
> **vision gate** (`postcardThemeGateCard`) for legibility/layout/design; score
> >= `postcard_theme.gate_pass_score` (default 78) saves `status=active`, else
> `draft`. Themes are data in **`postcard_themes`** (theme_key, source
> seed/ai/operator, style_prompt, bg_type color|image, bg_value, accent/ink/dark,
> status, gate_score/reasons, preview_url); `postcardFactoryThemes()` merges seeds
> (paper/navy/kraft) + active DB rows so a passing theme is instantly pickable in
> the Factory. Image backgrounds get an auto legibility scrim behind the left text
> column (`postcardFactoryHtml` `$bgIsImage`). `postcardFactoryHtml(...,$themeSpec)`
> takes a candidate spec so the Studio can preview before saving.
> Dev API: `dz factorytheme do=create style="..." [b=<id>] [match=1]`,
> `do=status key= set=active|rejected`, `do=list`. Admin: AI Postcards page has the
> AI Theme Studio card (style + business picker + match-site) and a theme gallery
> (activate/retire). The AI Postcards business picker uses the same
> `admin_business_search` autocomplete and its dropdown is opaque. First run:
> "beach vacation" scored 92, shipped a genuinely good card ("Tropical Escape").
> `dz factory compose b= [theme=] [angle=]`. The old full-AI painter (below) is the
> fallback (engine='ai'), kept for background-art experiments.
>
> **Themes (v2.2.382):** paper (light editorial), navy (bold dark), kraft (warm
> local). Theme colors derive from a `dark` flag + optional panel/muted/chrome
> overrides — adding a theme is a few lines in `postcardFactoryThemes()`. Layout:
> local eyebrow + headline that **auto-sizes to its length** (any Machi angle fits
> the fixed 6x4 card) + screenshot as a real `<img>` in a `overflow:hidden` crop
> box (full nav, no white gap) inside browser chrome showing the real domain.
> Render notes: QR inlined as a data URI (external QR service timed out the
> browser); `networkidle2` + paint delay; 429 backoff-retry.
>
> **Load-bearing bugfix shipped here:** `claimCodeCreate()` validated the DEFAULTED
> intent but assigned the RAW `$opts['intent']`, so any caller not passing intent
> (every postcard) failed with "Column 'intent' cannot be null" and got no code.
> The AI cards hid it by inventing a code; the Factory surfaced it. Real codes now
> mint network-wide.

## Sent & performance tracking + independent resend (v2.2.407)

Every send already mints its OWN claim code + doozer.link shortlink + Thanks.io
`order_id`, so each touch is independently trackable — the schema never limited a
business to one card. The admin Sent section (v2.2.409) is bounded so it never runs
wild at scale: three **last-5 glances** (Sent / Delivered / Touched = scan·visit·claim)
plus a full **All sends** table with search (business/code), campaign + status
(delivered/scanned/claimed) filters, sort-by-date, pagination, and a **Doozer+ column**
= full conversion (claimed + owner on a premium tier).

- **`postcardDesignLiveStats(array $design)`** (`postcard-ai.php`) reads outcomes LIVE
  from `thanksio_sends` (cost/order/preview), `thanksio_events` (delivered + QR scans),
  `shortlinks.click_count` (visits), `claim_codes.redeemed_at` (claimed). Never the cached
  JSON. `postcardAiTracking()` delegates to it + caches. The old code queried a
  non-existent `shortlinks.visits` column (always 0) — fixed to `click_count`.
- **Webhook** (`thanksIoHandleWebhook`) recomputes the linked design's tracking on every
  delivered/scan event, so the view stays fresh without a manual Refresh.
- **Resend:** `?ajax=resend` on `admin-postcards-ai.php` re-runs `postcardFactoryCompose()`
  for the same business + campaign + theme (theme recovered from `model` = `factory:<key>`)
  → a NEW `pending_review` card with a fresh code. Never auto-mails; operator Previews /
  Sends Real. Delivered/scan status fills in as Thanks.io reports it (minutes→days).
- **View front + back (v2.2.410):** each send's "View" button opens a lightbox with the
  Factory front + the real Thanks.io handwriting back. Proofs persist to
  `postcard_designs.proof_urls` on preview/send; `?ajax=proof` fetches them free on demand
  for older sends (a preview of a `sent` card is allowed — free, no mail) and caches. NOTE:
  Thanks.io previews live at `response.data.previews` (front + handwriting back, S3 URLs).
- **Cost bug FIXED (v2.2.409):** Thanks.io returns `authorization_total`/`leads_fee` ALREADY
  in cents (114 = $1.14 for a 4x6, 1 recipient); the send path was multiplying by 100 again.
  Dropped the `*100` (thanksio.php ~357); `dz postcard do=fixcosts` re-derives historical rows
  from raw meta.
- **No stray logo (v2.2.414):** the Factory top-right "logo" was `businesses.logo_url`, an
  unreliable scrape (often a site PHOTO, not a logo). Slot removed (`$logoBlock=''`); the
  screenshot already shows real branding. Re-enable only behind a trustworthy is-a-logo signal.
- **Size is a variable (v2.2.414):** 4x6 and 6x9 share the SAME 2:3 art (1800×1200), so no
  re-render — pick per send via the review-card selector (4x6/6x9/6x11). Plumbs postcardAiSend
  → thanksIoMailClaim → Thanks.io; recorded on `thanksio_sends.size`.
- **Format science (v2.2.414):** `postcardFormatPerformance($niche,$state,$days)` + a "Format
  performance" admin panel — funnel (sent→delivered→scanned→claimed→Doozer+ converted) by SIZE
  as a rate of sent, sliceable by niche/state/timeframe, with cost/claim + a thin-sample flag
  (n<30). Claim rate is the money metric.

## Roadmap (the Marketing Engine this seeds)

- **Phase 2 — Internal LeadBuilder / "Doozer awareness" CRM:** businesses are OUR
  prospects; every touch (postcard sent/delivered/scanned/claimed, site created, leads we
  deliver to them) rolls into a per-business awareness score → internal Hot Leads to convert
  free→Doozer+→Keeper→agency. Reconcile with [[machi]] rather than duplicate.
- **Phase 3 — BYO-key thanks.io reseller Marketing Engine:** Doozer+ businesses send
  direct-mail + AI marketing to their OWN leads. Needs a `getApiKeyForBusiness()` seam off
  the single `getApiKey()` chokepoint, per-business thanks.io key, owner send tool on
  `manage-business.php`, a 50% affiliate revenue ledger, agency/franchise tiers. Reuses the
  Factory + the two-score `/frontend-design` vision gate already built.

---

# AI Postcards — fully-generated claim mail with a quality gate (fallback painter)

**What:** The physical arm of the observed-world flywheel. When a business has no
email on file (unclaimable via implicit verification), we mail a real postcard
carrying the site's own screenshot, a claim code, and a scannable QR. The card
DESIGN is AI-generated end to end and passes a vision-model quality/accuracy gate
before any human sees it. No templated fallback — if the AI can't produce a good
card in 3 tries, it fails loudly for review instead of mailing slop.

**Engine — `includes/postcard-ai.php`**
- `postcardAiGather($businessId)` — assembles real ingredients: live site
  screenshot (`siteScreenshotUrlForHost`, Cloudflare Browser Rendering -> Spaces),
  a minted claim code + doozer.link QR (`claimCodeCreate`), business facts, logo.
- `postcardAiBuildPrompt($ctx)` — detailed 6x4 postcard art-direction prompt:
  use the provided screenshot faithfully as the browser-framed hero, exact
  business name / claim code / URL, reserve a QR square, Doozer green.
- `postcardAiGenerate($prompt,$refImageUrls)` — Gemini `generateContent` with the
  screenshot + logo passed as real `inlineData` image inputs (Gemini is the only
  configured engine that accepts image inputs). Needs an image-capable Gemini key
  in `api_keys` (service `gemini`/`google`/`google_gemini`).
- `postcardAiGate($imageUrl,$ctx)` — SCANS the generated image with a vision model
  (`callVisionProvider` openai/anthropic), returns JSON `{suitable,score,reasons}`.
  Pass threshold = `platform_settings` `postcard_ai.gate_pass_score` (default 75).
- `postcardAiRun($businessId,$opts)` — orchestrates gather -> up to 3
  generate/save/gate attempts. Suitable design -> `pending_review`; three failures
  -> `failed` with all three attempts + reasons kept.
- `postcardAiSend($designId,$opts)` — mails via `thanksIoMailClaim` (front_image_url
  = the generated card). `live` flag gates preview vs real mail (costs money).
- `postcardAiTracking($designId)` — rolls up Thanks.io scan events + doozer.link
  shortlink visits + claim-code redemption.

**Table:** `postcard_designs` (business_id, claim_code_id, screenshot_url, logo_url,
qr_url, redeem_url, prompt, image_url, model, attempts, gate_score, gate_verdict,
gate_reasons, status ENUM('generating','pending_review','failed','approved','sent',
'rejected'), failures JSON, thanksio_send_id, sent_at, tracking JSON).

**Admin — `includes/admin-postcards-ai.php`** (Admin -> Infrastructure -> AI
Postcards, route `postcards-ai`). Human-in-the-loop: ready designs show the card +
gate score with a Send button (live checkbox); sent designs show live tracking;
failed designs show 3 attempts + Retry. Generation runs as a background
`postcard_ai` job (dispatcher case), so the screen never blocks.

**Dev API:** `dz postcard gen b=<id>` (queue+run), `list`, `show id=`, `send id=
[live=1]`, `retry id=`, `track id=`.

**The claim loop:** sending sets `sent` and starts the state machine; a QR scan or
code redemption sets the owner's `businesses.admin_email` — the postcard IS proof
of claim (implicit verification, no claims table). See [[claim-codes]],
[[thanksio]], [[the-observed-world]].

**State:** shipped v2.2.379 (Jul 7 2026). Blocker for first real run: an
image-capable Gemini key must be in `api_keys`; vision gate keys (openai/anthropic)
already present. First target = Powell Flooring (business id 1, West Milford NJ,
email NULL, screenshot already captured to Spaces).

---

# Best AI for the Job — value-aware model selection

`systems/best-ai-for-the-job.md` · type: System · tags: ai, models, cost, quality, scoring, strategy, doctrine

> How Doozer decides which AI model to use for each function, when, why, and for what — balancing quality against cost by the value of the work, learning from its own scores, and staying provider-diverse on purpose.

# Best AI for the Job

Doozer does not use one model for everything, and it does not chase the cheapest
model. It uses **the best model for each job** — chosen intelligently, weighed by
the **value of the work against its cost**, and refined by what the work actually
scored. "The best man for the job", made mechanical.

## THE GOAL

> Build **Solid A** rated sites — accurate, beautiful, tasteful, helpful,
> valuable, useful — for **every business that should have one**. Par-create
> decides who should have one. The scoring of what we build drives the resources
> spent on the next build. The fleet average climbs generation over generation.

Solid A = weighted **≥ 85** across the [six goals](site-scoring.md)
(`dzQualityGoal()`, tunable `platform_settings.doozer_quality_goal`). This is not
a per-site retry loop — we build sites one after another, each using the current
best-known strategy, and the results steer the next build. When a choice raises a
niche's average we keep it; **when a choice regresses it, we undo that choice and
try another until the score climbs.** Doozer builds because — and it gets better
every generation, toward leading-edge, lead-generating sites at scale.

### The self-improving ratchet (`quality-autopilot.php`)

The unit of improvement is the **niche** — sites within a niche score alike, so a
strategy that lifts one lifts them all. Each niche holds a `niche_build_policy`
(the current strategy: model tier, deep-facts on/off, the baseline it's beating).
`qualityRatchetTick()` (`dz goal autopilot=1 go=1`):

1. A niche **below the goal** and un-escalated → escalate its next builds (GO HARD
   + deep source-of-truth). Record the baseline to beat.
2. An **escalated** niche → compare its fresh average to the baseline:
   improved → **commit** (new baseline, keep climbing); regressed → **revert** the
   choice and mark that lever "tried, didn't help" so the next pass reaches for a
   different one; flat → hold, gather more data.
3. **At/above goal** → leave the value-aware default (don't overspend).

The router reads the niche policy (`aiForJob($job, ['niche'=>...])`) and escalates
accordingly. `dz goal` reports fleet average, at-goal count, and per-niche standing
worst-first — the niches the ratchet is working on.

**First reading (July 2026):** fleet 80.3, goal 85, 12/46 at goal. Weakest niche:
**restaurant (74, accuracy 67)** — high fact density (menus/prices/specials) is
where content invents things. Escalated. Real-estate (84/acc 88) and non-profits
(84) are already near goal. The single biggest accuracy lever is reading the real
website: prefer the **free Jina source-of-truth cache** over the paid, deprecated
Firecrawl `deep_research` (v0 API) — same job, better accuracy, no cost. Retiring
Firecrawl for enrichment is the recommended consolidation.


This is doctrine, not a setting. It encodes how much care goes into a Doozer
site: the accuracy, the taste, the scoring, the deliberate balance of cost and
quality — and the deliberate refusal to become dependent on any one AI house.
Doozer builds because. It does not waste time, and it does not waste money — but
it never trades away real value to save pennies.

Code: `includes/ai-job-router.php` (the engine), `includes/ai-models.php` (the
model catalog + tiers + cost map), `includes/site-scoring.php` (the feedback).
Operator surface: `dz aimodels`, `dz aimodels pool=1`.

## The principle (Jon's words)

> "There is no reason to use a cheap model for even a slight loss in quality if
> the value is still hundreds of times the cost."

So the question is never "what is cheapest." It is: **is the quality I would give
up by going cheaper worth more than the money I would save?** If yes, GO HARD.

## Three tiers, one framing

| Tier | Framing | Anthropic | OpenAI | xAI | Google |
|---|---|---|---|---|---|
| flagship | **GO HARD** | Opus 4.8 | GPT-5 | Grok 4 | Gemini 2.5 Pro |
| balanced | **Solid A** | Sonnet 5 | GPT-5 mini | Grok 4 Fast | Gemini 2.5 Flash |
| budget | **Economy** | Haiku 4.5 | GPT-5 nano | Grok 3 mini | Gemini Flash-Lite |

The tier resolves to the **current best model in that class** via
`aiModelResolve()` — so when a new model ships, we update the catalog in one place
and every job that asked for a class is upgraded. Pin a concrete id only to freeze
a chore to a known model.

## The math (the sweet spot, per output)

Every AI function declares four things (`aiJobRegistry()`):

- **floor** — the lowest tier it may ever use (a quality floor).
- **value** — the dollars one *good* output is worth.
- **spread** — how much output quality *actually* varies flagship→budget for this
  job. A label is a label (spread ≈ 0.02: a cheap model nails it). Bespoke site
  copy is not (spread ≈ 0.15: the creative gap is real and worth paying for).
  Difficulty, not just value, decides.
- **typical tokens** — for the cost estimate.

The engine walks candidates best-quality-first (flagship → floor) and steps down
to a cheaper model **only when**:

```
Δquality × value   ≤   Δcost
```

The dollar value of the quality you'd lose vs the dollars you'd save. It stops at
the first step where quality is worth more than the savings — that is the sweet
spot. For a high-value output (a site that is a claimable lead), the pennies of
cost delta never outweigh the quality, so it **goes hard**. For instrumentation
(scoring) or trivia (classification), the cheap model is genuinely good enough.

Quality per tier starts from a difficulty-aware prior
(`1 − spread × tierPenalty`, penalty: flagship 0, balanced 0.5, budget 1.0) and is
then **blended toward measured evidence** as real scores accumulate.

## What the engine currently decides

- **GO HARD (Opus 4.8)** — content, content_flagship (hero/visitor builds),
  design/Muse, strategy, oracle, enrichment, marketing, conversation, vision.
  These touch the customer or feed accuracy; their quality is worth far more than
  the cost delta.
- **Solid A (Sonnet 5)** — scoring. It is internal instrumentation: quality-
  insensitive and high-volume, so Solid A is the sweet spot.
- **Economy (Haiku 4.5)** — summary, extraction, classification. Trivial, high-
  volume; a cheap model is as good, and at volume the cost is what matters.

See it live: `dz aimodels` prints job → choice → model → value → est cost → why.

## The learning loop (it gets smarter)

Priors are a starting belief, not the last word. Every scored site feeds
`aiRecordJobQuality(job, provider, model, score)` into `ai_model_quality`, so the
engine learns the **real** quality each model produces for each job — the score
from one model vs another, over real Doozer sites. Content generation records
which model wrote a site (`businesses.custom_settings.content_model`); when the
[six-goal scorer](site-scoring.md) rates that site, the score is credited to the
model that wrote it. Over time the decision sharpens from evidence, not assumption.

## Diversity — never platform-dependent

A small share of eligible calls (≈10%) is routed to an **alternate provider of the
same tier**. This is deliberate: it keeps quality estimates fresh across houses,
keeps every vendor warm and callable, and means Doozer is never hostage to one
AI company's pricing, availability, or policy. If one house stumbles, the work
keeps flowing. Diversity is resilience, and it is also honesty about the fact that
different models genuinely produce different results for the same prompt.

## Overrides (they win)

The algorithm is the default, not a cage. Per call: `['hard' => true]` forces
flagship, `['tier' => ...]` / `['provider' => ...]` pin a choice. Per job:
`platform_settings.ai_job_tier.<job>` and `ai_job_provider.<job>`
(`dz aimodels set job=<j> tier=<t> provider=<p>`). Only **callable** models
(API key present) are ever chosen, so it just works as keys come and go.

## Provider notes / gotchas

- **Thinking-capable models** (Sonnet 5; adaptive-thinking Opus) return a
  `thinking` content block *first* — code must scan `content[]` for the text block,
  never assume `content[0]`. Both `callAnthropic` and `generateWithClaude` do.
- **OpenAI GPT-5 / o-series are reasoning models** — they spend a large, invisible
  share of the completion budget on reasoning before the answer, so JSON/structured
  tasks need generous `max_completion_tokens` (the router path adds headroom).
- **Google Gemini** routes via its OpenAI-compatible endpoint; needs a `google`
  api_keys row to be callable.

## Still to wire (follow-ups)

- The flexible content path (`generateContentFlexible`) should set
  `__lastContentModel` too, so the learning loop captures every content route, not
  just `generateWithClaude`.
- Migrate the remaining hard-pinned call sites to `aiForJob()`:
  `muse-family-generator`, `muse-recompute-palette`, `ai-classifier`,
  `anthropic-chat`.
- A per-job monthly budget governor for very high-volume jobs.

## Why this matters

A Doozer site is built with care: real source material read and fact-checked, six
goals measured, taste enforced. The model behind that work is chosen with the same
care — the best tool for each job, paid for when the value is there, saved when it
is not, learned from what it produced, and never locked to one vendor. That is how
we create value without wasting time or money.

---

# Billing

`systems/billing.md` · type: System · tags: billing, stripe, monetization

> Product-agnostic Stripe billing — one connection sells everything, entitlements flow to lb_users.tier.

# Billing

One Stripe connection sells everything (Premium now; add-ons, domain-connect later).
`includes/billing.php` is the product-agnostic core.

- `billing_products` (SKU catalog; kind sub|one_time; `grants` entitlement; per-mode
  price ids), `billing_orders` (ledger), `billing_subscriptions`.
- Secrets are ENCRYPTED in `platform_settings` (not `api_keys`, not env). No SDK —
  a curl client. Checkout for both kinds; HMAC webhook to `billingGrantEntitlement()`
  (the same grant path as founders codes).
- Entitlement = `lb_users.tier`. Cancel only downgrades `premium_source='stripe'`.
- Cost protection: clouds (directories over existing sites) are unlimited; creating
  BRAND-NEW sites is metered per user per month (`user_site_creations` +
  `platform_settings billing.limit_new_sites_pm`). See [site creation](../processes/site-creation.md).
- Self-serve via Stripe Customer Portal (`billingCreatePortalSession()`).

Admin: the Revenue nav group (`admin-billing.php` + `admin-users.php`). Adding a product
is a Stripe Price + a catalog row, no new plumbing.

---

# Cloud Header Images

`systems/cloud-headers.md` · type: System · tags: clouds, design, images, ai

> Every cloud (and root) gets a header. Real image > Keeper opt-out gradient > a designed per-cloud placeholder. AI generation is the upgrade path.

# Cloud Header Images

A [Cloud](../glossary/cloud.md) should never read as blank just because its Keeper has
not yet uploaded or generated a header. As of v2.2.197 every cloud has a header by
default. Engine: `includes/cloud-header-ai.php`.

## Precedence (`cloudHeaderTopCss($img,$src,$name,$c1,$c2)`)

1. **Real image** (`header_image_url` set) → the image with a gentle legibility overlay.
2. **Keeper opt-out** (`header_image_source = 'none'`) → a plain brand-color gradient.
   This is a deliberate manual choice (a "No header image" checkbox in the cloud editor),
   never the default.
3. **Otherwise** → a designed, per-cloud **placeholder** (the default).

## The placeholder (free, instant, distinct)

`cloudHeaderPlaceholderSvg($name,$c1,$c2)` builds an SVG from the cloud's own brand colors:
a diagonal gradient, a soft light bloom, a seeded ring motif (deterministic per cloud via
`crc32(name)` so each differs), and a faint serif monogram of the cloud's first letter.
Served as a data URI (`cloudHeaderPlaceholderDataUri()`), so it costs nothing and renders
immediately. Used on the [Discover Clouds](../index.md) cards (`includes/discover-clouds.php`).

## AI headers (the upgrade)

`cloudGenerateHeader($cloudId,$provider)` composes a header from the cloud's vibe (type,
niches, place, colors), never a literal image, via GPT Image or Gemini Nano Banana 2
(v2.2.11). Per-cloud "Generate AI header" lives in the cloud editor; a batch
"Generate missing headers" (Admin → Clouds) backfills clouds with none, capped per run
because each call costs an image credit (operator-triggered, respecting
[API-cost discipline](../index.md)). The placeholder fills the gap until an AI or uploaded
image lands.

Decision: headers are on by default (placeholder), opting out is manual, real images are
the goal. Taste first, no blanks, no surprise spend.

## The flowing-cloud movement engine (v2.2.516)

Distinct from the header IMAGE above: the animated cloud LAYER over the hero
gradient. `includes/cloud-texture.php` → `cloudTextureLayer()` (drop-in on
cloud-directory, discover-clouds, cloud-create, me-cloud). A WebGL fragment
shader — fractal noise (fbm) + domain warping, animated by time — so the clouds
genuinely FLOW and morph (not a panning texture). Inspired by OpenAI's GPT-Live
hero backdrop, rebuilt from scratch.

Every look is a uniform, driven from the my.doozer.cloud vibe controls:
- **Mood** (`--c1/--c2`) → cloud colour; call `window.dzClouds.refreshPalette()`.
- **Energy** (0..4) → `window.dzClouds.setEnergy(n)` (speed + vividness).
- **Storm** (new slider, Clear→Tempest) → `window.dzClouds.set({storm})` ramps
  warp amplitude, turbulence, speed, and contrast.
- Also `set({speed,vivid,tintAmt})`; all persist to localStorage.

Responsible: DPR capped 1.75, `low-power`, paused when the tab is hidden or the
header is off-screen (IntersectionObserver). Graceful fallback: no WebGL or
`prefers-reduced-motion` → the original two-layer SVG fractalNoise texture
(`cloudTextureSvgUri`), kept intact. Self-contained, no CDN.

---

# Clouds (curated directories)

`systems/clouds.md` · type: System · tags: clouds, directory, curation, cloud-factory, doozer-cloud

> The cloud engine — how niche/region/criteria directories are built, accessed, and personalized, including the Cloud Factory and my.doozer.cloud.

# Clouds

A [Cloud](../glossary/cloud.md) is a curated directory of businesses grouped by
niche, region, or arbitrary criteria. Clouds sit on the same
[engine](architecture.md) as roots and NBMN, rendered through the shared
[multi-view framework](directory-framework.md) and [business card](shared-business-card.md).

## The engine

`includes/cloud-engine.php`. Clouds use `is_active` (NOT `status`). Membership is a
**filter** (states / cities / geo bounds), not a fixed radius, so large areas work.
Manual curation can include/exclude specific businesses (admin Members panel).
`doozer.cloud` apex is the public **Cloud of Clouds** (Discover Clouds). Each cloud
carries an AI-generated [vibe header](cloud-headers.md).

## Access model

Public (AUTO or CLOSED join), Unlisted, or Private, so a cloud can be an open
regional directory, a curated collection, or a members-only list.

## Cloud Factory

`includes/admin-cloud-factory.php` (Decide → Build → Manage): give it a niche +
region and it creates the cloud, runs a [Prospector](prospector.md) sweep, and
batch-builds the discovered businesses (which auto-join). Geo areas reuse
`googleGeocodeSearch` bounds. This is how a themed directory goes from idea to
populated in one flow.

## Personal — my.doozer.cloud

Each signed-in user gets a personal cloud (`includes/cloud-directory.php` personal
branch) with a **Vibe System**: Mood (color) x Energy (0-4) preference; headlines
follow energy via `meCloudHeadlineBank()`; reduced-motion keeps colors. This is the
consumer-facing, "your neighborhood, your way" surface. Saved businesses derive
their URL from the business's ACTIVE domain, never a stored `lb_follows.domain_id`.

Clouds turn the flat fleet into infinite meaningful cross-sections, each a new
discovery surface feeding [the flywheel](the-flywheel.md).

---

# Content Generation Pipeline (providers, models, timeouts)

`systems/content-generation.md` · type: System · tags: ai, content, providers, reliability, seo

> How AI site prose is actually produced — the two provider paths, model config, the 90s timeout, the anti-boilerplate footer, and every failure mode that bit us.

# Content Generation Pipeline

This is the engine behind [Content Generation and Healing](../processes/content-generation-and-heal.md).
It produces `businesses.ai_generated_content` (headline, about, services, why_choose_us, faq,
meta_description, suggested_colors). Hard-won operational truths live here — read before touching
AI content, model config, or regeneration.

The governing principle (why site voice varies so much place to place) lives in
[Per-Location Data Enrichment](location-data-enrichment.md): a site's voice is signal-in,
signal-out. The lever for a thin site is more/better input data, never a forced tone.

## Entry point + the TWO paths (keep both in sync)

`generateBusinessContent($businessRow)` in `includes/ai-content.php` is the entry. It takes the
**business row** (from `getBusinessById()`), NOT a Google-Places shape. There is **no**
`generateContent()` — calling that name silently broke every content regen with "Content generator
unavailable" (fixed v2.2.235).

It tries two paths, and a change to ANY of prompt / timeout / model must cover BOTH:

1. **Flexible path (tried FIRST, used in production):** `generateContentFlexible()` in
   `includes/ai-provider-system.php`. Reads the per-content-type model config, builds the prompt
   from the DB template, calls `callAnthropic()` / `callOpenAICompatible()`.
2. **Legacy path (fallback):** `ai-content.php`'s own `callAnthropic`/`callOpenAI`. Has its own
   model resolution + the historical default `claude-3-5-sonnet-20241022`.

DURABLE RULE: the flexible path is what runs ~always. If you only patch `ai-content.php` you patched
the wrong path. (This exact trap meant the anti-boilerplate footer didn't apply for a full cycle —
see below.)

## Model config (settings, not code)

Per-content-type provider/model lives in the **`ai_content_configs`** table (rows: `business_website`,
`photo_vision`), joined to `ai_models` + `ai_providers`. Set it via **Admin → AI Management**
(it's under the **Infrastructure** menu, `?page=admin&section=ai` → Content Configuration → Configure),
NOT a migration. Class aliases resolve via `aiModelResolve()` over the `includes/ai-models.php`
catalog.

DURABLE RULE: the content **primary model must be a FAST quality model — `claude-sonnet-4-6`.**
Do NOT set a reasoning model (`gpt-5`, o-series) as the synchronous content primary: they take far
longer than the timeout for a 2048-token generation and every call aborts with HTTP 0. (gpt-5 as a
primary is what caused the June 2026 network-wide content outage.)

## Timeout (why 90s)

Content asks for up to **2048 output tokens**; a quality model needs **25-60s** for that length.
The historical **30s** cURL timeout aborted generation mid-stream → cURL returns **HTTP 0**. Fixed:
`AI_TIMEOUT_SECONDS = 90` in `config/app.php` (legacy path) AND `callAnthropic`/`callOpenAICompatible`
in ai-provider-system.php default their cURL timeout to `AI_TIMEOUT_SECONDS` (v2.2.238). Both paths,
or it regresses. Safe because content gen runs via the queue worker (post-response) or CLI batches,
and admin synchronous regen is rare/one-at-a-time — not a visitor FPM hot path.

## Anti-boilerplate (the SEO-load-bearing part)

`_aiContentProseQualityFooter()` (ai-content.php) prepends the shared
[Design Standards](design-standards.md) rules + an ORIGINALITY block that BANS templated openers
("nestled", "in the heart of", "stands as a beacon", "more than just a", "steeped in tradition") and
em-dashes, and requires a page-specific opener built from real data. It is appended in BOTH paths
(legacy always; flexible since v2.2.239). Without it, similar businesses get near-identical prose —
the near-duplicate cluster that triggered the [elks.club scaled-content demotion]. See the
`project_seo_cliff_recovery` memory + [site-quality scoring](dev-api.md) for measuring boilerplate.

## Failure modes (decode these fast)

- **HTTP 0** — cURL timeout (slow model + tight timeout) OR connection failure. If `dz ai_ping`
  shows providers 200 in <1s, it's the model/timeout, not the network.
- **HTTP 400 "Your credit balance is too low"** — the provider account is out of prepaid credits.
  This stalled a batch mid-run (~1,158 calls drained Anthropic). It IS logged (callAnthropic logs
  4xx) but to error_logs in **UTC** while batch logs print `America/New_York` — a ~4h offset that
  makes it look unlogged. Check instantly with **`dz ai_status`** (flags billing/credit/auth).
- **"Content generation returned nothing"** — `generateBusinessContent` returned null (both paths
  failed). The `regenerationRun` result carries a `detail` / `$GLOBALS['__lastContentGenError']`
  with the real per-provider cause; surface that, don't guess.

## Regeneration + batch rollout

`regenerationRun($businessId, 'content')` ([regeneration.php]) wraps `generateBusinessContent` with a
readability quality gate (keeps prior content if the new draft fails) + manifest stamp. Fleet rollout
= worst-first over [site-quality-score](dev-api.md) boilerplate-flagged pages, rate-limited, resumable
(re-run skips now-clean). ALWAYS `dz ai_status` first — a big batch on an empty credit balance fails
silently. Operator preference: drive batches via the [Dev API](dev-api.md), not raw `php -r` pasted
into a root shell.

## Diagnostics

- `dz ai_status` — live provider health + billing/credit alert + recent provider errors (UTC-correct).
- `dz ai_ping` — minimal keyed probes to both providers (egress vs auth vs timeout).
- `dz quality root=<root>` — boilerplate/flag rollup to measure de-boilerplate progress.

See also [hosting infrastructure](hosting-infrastructure.md) (the queue + drain) and
[design standards](design-standards.md) (the prose rules enforced at the source).

---

# Context-city poisoning (never name a place after where you found it)

`systems/context-city-poisoning.md` · type: System · tags: geo-anchor, accuracy, urls, par-create, prospector, lesson

> Two independent creation paths named businesses after the city they were DISCOVERED from rather than where they are, putting the wrong town in 239 URLs. The pattern, the fix, and the audit.

# Context-city poisoning

**The bug shape:** a creation path knows two cities — the one it is *looking from* and the
one the place is *in* — and uses the wrong one. It happened twice, independently, in two
subsystems written months apart. Assume it will happen a third time.

| Path | Used | Should have used |
|---|---|---|
| `parCreateSkeleton()` | the ANCHOR business's city (the page the visitor was on) | the place's own address |
| Prospector discovery | the SWEEP CENTER's city (`$resolvedCity = $center['city']`) | the place's own address |

Results in the wild: a Brooklyn bowling alley at `melody-lanes-morristown-nj.doozer.site`,
a Brentwood restaurant at `blaze-pizza-nashville-tn`, a New York restaurant at
`tao-downtown-restaurant-jersey-city-nj`.

## Why it is easy to miss

The context city is *almost* right. Most results of a Morristown sweep really are in
Morristown, so the bug hides in the minority and every spot-check passes. It only shows up
when you compare the slug against the address **systematically**. 239 of 3,439 domains
(1,304 of which use the `city-state` slug shape) named the wrong town before anyone noticed.

It is also invisible in the product: the page content, title and schema were all correct,
because those read `businesses.formatted_address`. Only the URL lied.

## The rule

> A city is a **fact about the place**, never about how you found it. Discovery context is
> a hint for routing and a provenance note. It must never name anything.

Par-create records this correctly for provenance: `custom_settings.discovered_on` keeps
where a skeleton was seen, while `city` comes from the address. That is the right split.

## The fix

`parCreateCityStateFromAddress($addr)` in `par-create.php` parses the place's own city and
state from the address we already hold, and returns `['','']` rather than guessing when it
cannot parse confidently, so callers fall back rather than invent. Both paths call it now.

Watch the address shapes: Google returns both `"912 Red River St, Austin, TX 78701"` and
`"222 W 1st Ave, Plentywood, MT 59254, USA"`. Strip a trailing country, then take the part
before the `ST ZIP` tail. Reject a candidate that starts with a digit, which means you
grabbed a street line.

## The audit

`dz slugaudit` — scans every active domain, extracts a trailing `-city-state` slug, and
compares it to the address. Reports, dry-runs, repairs.

**It splits the fix by risk, and that split is the important part:**

- **not in the sitemap** → safe to re-slug. 217 were repaired this way.
- **in the sitemap** → refuses unless forced, because renaming an indexed domain used to
  be a deletion plus a creation. **Resolved in v2.2.541:** see below. All 22 are renamed
  and the network now audits clean at 0.

## Renaming a domain (the mechanism that was missing)

`includes/domain-redirects.php`. A retired host keeps its `domains` row with
`status='disabled'` (already means "not serving", so no migration) plus
`custom_settings.redirect_to`, and `domainRetiredRedirect()` 301s it.

Two properties make it safe to leave switched on:

- **Zero render-path cost.** The lookup runs only where a host has no ACTIVE domain row —
  precisely where the configure wizard used to load. A live page never reaches it. Adding
  a query to all 3,400 sites' hot path to serve a handful of retired hosts would have been
  the wrong trade.
- **Open-redirect guard.** The target must be https, carry no userinfo, and resolve to a
  host we actually run. We are the only writer today, but a redirector that trusts a
  database string is how you become somebody else's phishing hop later.

Rename procedure: UPDATE the live row's `domain` (keeping id, page-view history and
sitemap membership), then INSERT a disabled row for the old host carrying the redirect.
Then push the new URL to IndexNow so Bing-backed search sees the move.

**A correction worth keeping.** The original justification for leaving indexed domains
alone included "the orphan falls into the configure flow and offers a stranger the chance
to configure somebody's business." That was wrong: `runRootDomainGuard()` already returned
a 302 with `x-doozer-lockdown: blocked-subdomain` for unregistered subdomains. The real
value of retirement is the **permanent 301** telling a search engine the address moved,
not protection that already existed. Verify the claim, not just the conclusion.

## Transferable lesson

When a naming bug is found in one subsystem, **grep for the same shape everywhere else
before declaring it fixed.** The instinct that caught this was simply asking whether the
bug had happened before. Both paths had it; only one was found by working on it directly.

Related: [[the-observed-world]], [[fact-guard-identity-numbers]], [[dev-api]].

---

# Design Standards and the Design Mirror

`systems/design-standards.md` · type: System · tags: design, quality, self-diagnostic

> One shared rule-set the generator, renderer, and admin self-diagnostic all consult — plus the mirror that scans and heals the fleet.

# Design Standards and the Design Mirror

The single source of truth for "what a tasteful Doozer site looks like" is
`includes/design-standards.php`. It exists so the design layers stop patching the same
flaw in isolation. Three layers consult it:

1. **Generation** — the AI content prompt (`includes/ai-content.php`) injects
   `designStandardsPromptRules()` so new content is born compliant (never bold a whole
   paragraph; only short phrases; no em dashes; 2 to 3 paragraphs).
2. **Render** — `includes/prose-render.php` delegates to `designStandardsTameInline()`
   and `designStandardsStripEmDash()` as a safety net for non-compliant model output.
3. **Diagnose** — the [Design Mirror](#the-mirror) scans the fleet and heals it.

## Rules

`designStandardsRules()` is an extensible registry. v1 rules: `over_emphasis`,
`em_dash`, `wall_of_text`, `empty_content`. Adding a rule is one array entry.

## The Mirror

`includes/admin-design-mirror.php` (Admin → Design Mirror) scans live sites against the
registry, shows per-rule counts and percent-of-fleet with sample offenders, and offers
one-click remediation: **Heal** (reheal rules: re-saves cleaned content in place, zero
API cost) and **Queue regeneration** (regen rules: enqueues content jobs). It never
auto-acts; the operator controls spend.

## The rule

New design-shape problems get a registry rule, NOT a one-off override. That is the
anti-bandaid principle that motivated this system. See the
[content generation and heal](../processes/content-generation-and-heal.md) process and
the [no dark patterns](../decisions/no-dark-patterns-ethos.md) ethos.

## The regeneration readability gate

The [unified regeneration engine](../processes/content-generation-and-heal.md)
(`includes/regeneration.php`) uses this registry as its safety net: after generating new
content it scans, heals once, and rolls back to the prior content if the result is still
broken, so a regeneration can never leave a live site worse. What counts as "broken" is
derived from the registry, not hardcoded: `designStandardsBlockingRuleIds()` returns the
rules a heal cannot fix (`fixable = 'regen'`, currently `empty_content` and `wall_of_text`).
Cosmetic rules (`fixable = 'reheal'`, e.g. over-bold prose and em-dashes) are auto-healed
and NEVER block. This encodes the operator principle: reject broken or unreadable output,
never reject a site for being bold or expressive. Any future `regen`-fixable rule joins the
gate automatically.

(History worth keeping: this gate shipped dead in v2.2.205 and was a silent no-op until
v2.2.211 — it scanned for a severity vocabulary and scan shape the registry never produced.
The lesson, again: verify the wiring actually fires, do not assume it does.)

## Scope: content shape, not visual CSS

This system governs the SHAPE of generated CONTENT (prose emphasis, em dashes, wall of
text, emptiness). Visual CSS safety, such as AA contrast and guaranteeing an outline
button is actually visible, is a separate concern handled at render time by the
[Muse System](muse-system.md)'s composition layers (`muse-contrast.php` plus the
button-visibility safety in `muse-apply.php`). If a control is invisible or low-contrast
on live sites, look there, not in this registry.

---

# Directory Multi-View Framework

`systems/directory-framework.md` · type: System · tags: directory, views, grid, list, map, events, framework

> The shared Grid / List / Map / Events view system that every directory surface (root, Cloud, NBMN) renders through.

# Directory Multi-View Framework

One framework renders every directory surface in the [network](the-network.md) —
roots, [Clouds](clouds.md), [NearByMeNow](nearbymenow.md) — in multiple views
without each surface reimplementing them. `includes/directory-views.php` owns the
registry and the JS contract; a view is toggled by an `html[data-dir-view]`
attribute, and a surface opts into a view by rendering a
`data-dir-view="<id>"` panel and flipping it ready.

## The views

- **Grid** (live) — the default card wall, using the shared
  [business card](shared-business-card.md).
- **List** (live) — dense CSS rows with a subdomain/site pill.
- **Map** (live) — a shared Leaflet engine fed by `?ajax=root_map_markers` /
  `cloud_map_markers`; listings carry lat/lng.
- **Events** (live) — upcoming events per business via
  `businessUpcomingEventsBulk()` (`includes/business-events.php`), fed by
  `root_events` / `cloud_events` ajax; vertical directories like
  [Live Music Tonight](livemusictonight.md) render from `marquee_events`.

## Distance sort

Root and Cloud both support distance sort (`resolveRootDomainOrder` /
`cloudFetchListings` + a shared geo helper + `?e=geo`), so "nearest first" works
anywhere.

## Why one framework

Adding a view once lights it up across every surface. The cost is the shared
contract's discipline: a new card action must be registered where each host routes
its actions (e.g. NBMN routes its own card actions in `nearbymenow/index.php` — a
new action added only to the generic handler silently no-ops there). This is the
directory analogue of the "one renderer, many surfaces" principle behind the
[business card](shared-business-card.md) and [Muse system](muse-system.md).

---

# Doozer Atlas (outward AI emission)

`systems/atlas.md` · type: System · tags: atlas, ai, knowledge, okf, well-known, outward

> The public, per-entity knowledge contract Doozer emits so external AIs can ingest the network — the outward-facing twin of the inward Brain.

# Doozer Atlas

The **Atlas** is Doozer's outward AI knowledge emission: a public, structured
projection of what the network knows, published so external AIs and agents can
ingest it. It is the mirror image of the [Brain](../decisions/okf-adoption.md) —
Brain is inward (how Doozer understands itself, this bundle you are reading), Atlas
is outward (how the world's AIs understand Doozer).

## The contract

Every entity emits a public `/.well-known/doozer-atlas` document
(`okfEntityFor*()` in `includes/okf-engine.php`) for its type: **Business**,
**Cloud**, **Directory**, **Event**. The Atlas owns the CONTRACT — a derived
projection over live data. There is **no separate graph database**; the Atlas is
materialized from the same tables that render the pages, so it can never drift from
reality.

## Why it exists

Search is shifting from links to answers. If an AI is going to answer "what's a
good florist near West Milford," Doozer wants the network to be the citable,
machine-readable source, with accurate structured data (hours, geo, niche,
reviews, events). The [JSON-LD on vertical directories](livemusictonight.md) is the
same instinct at the page level. This is a moat: a network that is legible to AIs,
by design, is the one that gets cited.

## Relationship to this bundle

This `knowledge/` OKF bundle (exportable as one portable `.zip` via Admin → Doozer
Brain) is the Brain. The Atlas is the per-entity public feed. Both use the same OKF
engine and the same "it's just files / it's just structured data" philosophy
(see [OKF adoption](../decisions/okf-adoption.md)). Grow the Brain and the Atlas
gets richer context to project.

---

# Doozer Digest (AI daily newsletter)

`systems/doozer-digest.md` · type: System · tags: digest, newsletter, email, growth, ai, signals

> An AI-written daily email — a public reader edition and an operator admin edition — built from real platform signals.

# Doozer Digest

An AI-composed daily email, in two editions, each built from the same real
platform signals the rest of the network runs on (never invented).

## Public edition

A reader-facing daily digest (new sites, notable local businesses, events) that
turns the growing [network](the-network.md) into a reason to come back. Shipped
pieces: the digest generator, an AI intro, and sponsor slots. Next: recipient
management / opt-in and the send cron.

## Owner edition (per-business stats)
A third edition for a CLAIMED business's owner: their numbers in their inbox
("people are finding you"). `includes/digest-owner.php` — `digestBusinessStats()`
aggregates views, calls, directions, website clicks, saves, and leads for a window
plus the prior window (trend arrows) and lifetime reach, all from the same real
signals (page_views, click_events, lb_follows, nbmn_lead_requests, shortlinks).
Two tiers, one template: **Doozer+** gets all six metrics with trends; **claimed
(free)** gets two headline numbers + an audience-aware upsell via
`doozerPlusOfferFor()`. Tier resolves through the implicit-verification rule
(admin_email authoritative, else scraped email -> owner's `lb_users.tier`). Preview
with `dz digest owner=<id> [tier=plus|free] [render=1]`. Built and previewable;
NO send path is wired yet (recipient management + cadence are operator-approved).

## Admin edition

An operator-facing daily brief: growth, revenue, key metrics, and an
"idea-of-the-day." It is the human-readable companion to the
[admin health tools](admin-health-tools.md) — the same fleet supervised through a
morning email instead of a dashboard sweep. It shares the signal miners with
[Marketing](marketing.md) (changelog, LeadBuilder metrics, recent discoveries), so
the numbers are the ones that actually run the business.

Both editions are downstream of the [value model](../decisions/the-value-model.md):
the public edition drives retention/discovery, the admin edition drives operator
awareness. Roadmap order: NBMN demand-gen → Admin Digest → [Admin Alerts](admin-health-tools.md).
See [[project_doozer_digest]].

---

# Doozer Town and the Board Game view

`systems/doozer-town.md` · type: System · tags: doozer-town, clouds, growth, board, groups, geo, self-building

> doozer.town builds a page for every town in America the first time somebody asks for it, sorts its businesses onto ten colour groups, and can show any Doozer Cloud as a property board. Cost model, grouping rules, the edge blocker, and what to watch.

# Doozer Town and the Board Game view

**The operator's two ideas (Sept 2026), which turned out to be one:** a Cloud view that
turns any cloud into a Monopoly-style board with businesses as property cards grouped
into colour-coded categories; and `doozer.town`, dynamic self-building town clouds for
every town in every state. The grouping the board needed is the structure the town page
needed. Built in one pass as v2.2.615.

## The address is the instruction

`west-milford-nj.doozer.town` = West Milford, NJ. The slug parser accepts
`<city-words>-<st>` only; the state must be real. Anything else is a 404, not a town.

A visit resolves the slug in cost order (`townEnsure()` in `includes/town-engine.php`):

| step | source | cost |
|---|---|---|
| existing town cloud | `clouds` row by host | $0 |
| businesses we hold in that city/state | centroid of our own geo-verified rows, 6 mi radius | $0 (1,955 city/state pairs qualify today) |
| unknown town | one Google geocode | $0.005, bot-gated (`_nbmnRequestIsBot`), `town_create_max_per_hour` (30), and only when the result names the same place in the same state |

`asdfgh-nj` returns `not_a_place`; a crawler on an unknown town gets a 404 "not on the
board yet" and spends nothing. A town is a normal `clouds` row (`cloud_type=geo`,
`visibility=unlisted` so Discover Clouds stays curated) with a `town` block in
`criteria` (city, state, county, slug, source, last count). It rides the cloud engine
unchanged, so saves, counts and the card renderer all work. **A radius, not a city-name
match**, is what makes "serves West Milford" true: Hewitt, Newfoundland, Oak Ridge,
Ringwood and Greenwood Lake sit inside it.

## The ten groups

`townGroups()` / `townGroupKeyFor()`: Eat & Drink, Shops, Home & Trades, Health,
Self-care, Auto & Around, Civic & Community, Money & Pros, Stay & Visit, Utilities &
Essentials, plus Everything Else. Resolution order: explicit slug/type rules (because
`businesses.primary_niche` mixes catalogue slugs and Google types, and a business's
`types` JSON is consulted too), then the catalogue category, then keywords in the name.
An empty group is an **open lot** on the page and on the board and links straight to
Find, because the brand's mark is a grid with one cell still open. West Milford after
the first pass: 355 businesses, 10 of 10 groups, 34 in Everything Else.

## Money moves only on a tap

Rendering a town costs nothing. Find calls the existing `?e=nbmn_populate`
(cache-first, bot gate, per-IP and global paid caps, the $10/day Places cap) around the
town centroid; Build posts to the existing `?e=nbmn_build_place` (hourly cap) and lands
the site on doozer.site, where the town's radius picks it up on the next render. The
county eyebrow is one Magellan geocode per town, once, and only for a human.

## The Board Game view

`renderCloudBoardView()` in `includes/cloud-board-view.php`, `?view=board` on any
cloud and on every town. Forty spaces: corners Start / Saved / Free parking / Build,
nine per side. `boardDealSpaces()` deals a balanced hand: every non-empty group gets a
space, empties get an open lot, the rest is largest-remainder proportional, capped by
supply. More than 36 members lists the whole deck underneath (CSS columns, not a grid;
a grid left three empty columns beside one 66-item group). `?deal=n` reshuffles within
groups with the top third kept as anchors. Tapping a space opens a `<dialog>` deed with
photo, rating, address, call and visit. Pure renderer, spends nothing.

## The edge (Plesk)

Every wildcard root is a `ServerAlias *.<root>` line in the subscription's additional
Apache directives (HTTP and HTTPS both). On the day it shipped `*.doozer.town` briefly
answered from another vhost until that line was in; `curl --resolve` against the origin
IP is the only way to see past Cloudflare when that happens. With the alias live,
`town_wildcard_live=1` makes the apex path form `doozer.town/<slug>/` a 301 to the real
address; the path form stays as a permanent link shape.

## Operate it

- `dz town slug=<city>-<st>` resolves (creating from inventory) and reports groups,
  also-serves and nearby towns; `create=0` to look only; `force=1` to allow the
  geocode path from the dev channel (its curl UA reads as a bot otherwise).
- `dz town list=1 [state=NJ]`.
- Telegram kind `town_created`.
- Town pages are `noindex` under 5 businesses and on every filtered view.

## The contract: a person arrives, the site builds

Every skeleton exists for free until a person opens it; that visit fetches the
details (photos, website, phone, hours), writes the page, and the site is built by the
time they look around. The operator's rule (10 Sep 2026): **if a person is standing on a
site that has not built itself, that is a bug**, never a budget decision. Two
consequences. Paid backfills are not the way to fill boards; the visit is. And the
crawler checks that protect the budget must never catch a person: no bare IP-range
guesses (a /16 for Cloudflare's renderer also matched iCloud Private Relay), only named
agents, datacenter first octets, cached reverse DNS, and our own capture marker.

## Pre-filling a town (the par-create method)

`dz town prefill=1 slug=<slug>` is a dry run: for each group's niches it says whether the
nearby-discover bucket is warm (somebody already paid for it: $0 skeletons) or cold (one
Nearby Search, $0.035). `live=1` creates skeletons from warm buckets; `budget_usd=2`
lets it spend on cold ones, thin groups first. A full town is ~58 niches, about $2.
Skeletons land on doozer.site (noindex), are enriched by their first human visitor
(~$0.06 to $0.12), and appear on the board immediately through the radius. Pace it
inside the Places hourly ceiling (one town an hour) and the daily cap.

## What the first day cost, and why it will not again

$10 in a day: $8.85 was an image crawler fetching uncached gallery photos on 284
business sites in four minutes, not the towns ($0.63). The photo proxy now refuses paid
fetches for crawlers, the ledger has an hourly ceiling, and card photo sizes snap to the
cached ladder. The town page itself renders for $0.

## Where it sits in the ecosystem (Sept 2026)

Four surfaces, one loop. **NearByMeNow** is search: what are you looking for.
**Doozer Town** is place: everything that serves one town, ranked. **Business sites**
are the entity: one page per place, the thing the other two point at. **Clouds** are
curation: a Keeper's chosen set. The loop is wired both ways: every business site's
"Around the neighborhood" links to its town board, every board links to every site, the
network footer carries Doozer Town everywhere, and the apex sitemap (221 indexable
towns) goes to Bing by IndexNow. Bing is the channel; towns are the pages Bing can rank
for "<town> businesses" that a single business site never could.

Growth runs through the town: the pre-fill trickle feeds par-create from the biggest
boards, skeletons appear on the board at $0, a person's first visit enriches one, the
ranking decides who leads a group, and sponsorship (a labelled slot above the ranked
list, never inside the score) is where the revenue lives when it comes. Honorary
Doozers are the people who press Find and Build on a board they care about.

Every part of that spends only on a person, and every guard has a ledger line.

## What is deliberately not done yet

No Atlas or llms.txt entry for towns yet (sitemap, robots, footer link and the
site-to-town link shipped 10 Sep); no town list beyond what our inventory implies (a Census gazetteer would let the apex list every
place in a state). The group rules will keep needing words added as Everything Else
shows what the rules miss.

Related: [[clouds]](clouds.md) · [[the /now surface]](the-now-surface.md) ·
[[growth engine]](growth-engine.md) · [[brand system]](brand-system.md) ·
[[Magellan and the feed pond]](magellan-and-feeds.md)

---

# Email Open Tracking

`systems/email-tracking.md` · type: System · tags: email, tracking, intent, comms, alerts, analytics

> A 1x1 pixel in every outbound email; opens recorded with device/geo/bot-flag; three operator alert signals plus data for the digest.

# Email Open Tracking

Every email `doozerSendEmail()` sends (the single mail chokepoint) gets a unique
1x1 tracking pixel and an `email_sends` row. `includes/email-tracking.php`.

## What is captured
- **Pixel endpoint** `?e=eopen&t=<token>` (sessionless, returns a 1x1 gif, records
  AFTER flushing so it never adds latency). `email_opens` rows carry device
  (mobile/desktop), coarse geo (`CF-IPCountry` header when present), `ip_hash`,
  and a **bot flag**.
- **Bot / prefetch detection**: Apple Mail Privacy Protection, Google/Yahoo image
  proxies, and any open within ~8s of send are flagged `is_bot=1` and kept out of
  the alerts (still counted separately). `open_count` vs `human_open_count`.
- **Age is computed in the DB** (`TIMESTAMPDIFF(SECOND, sent_at, NOW())`), never
  from PHP `time()` vs a MySQL timestamp — that mismatch false-flags prefetches.

## The three alert signals (📬 `email_open` ping)
1. **Old email opened** (>= 14 days after send) — the headline: a dormant thread
   reawakening means something is up. Alert-level, 6h re-throttle per send.
2. **Unusual attention** — >= 4 human opens in 20 min. Hourly re-throttle.
3. **High-signal first open** — first human open of an important kind
   (`lead_forward`, `claim`, `owner_digest`, `lead`).

All opens are stored regardless, so the [Digest](doozer-digest.md) can report
opens / devices / repeat-rate even when no alert fires. `dz emailtrack` for an
overview; `dz emailtrack do=selftest [age_days=]` to exercise the flow.

## Related
Tag an email with `['kind' => …, 'business_id' => …]` in the `doozerSendEmail`
opts so alerts and the digest can attribute it. `['no_track' => true]` opts out.
The daily digest email to the operator is itself tracked (dogfooding).
See [[doozer-comms]] for the ping plumbing and [[notify-generously]] ethos.

---

# Enrichment Store Links (commerce "Shop Now" CTAs)

`systems/enrichment-store-links.md` · type: System · tags: commerce, store-links, shop-now, monetization, confidence-gate

> Turning a business's real online store into a tasteful commerce CTA, with a confidence gate so we never send a visitor to a bare marketplace homepage.

# Enrichment Store Links

When [enrichment](location-data-enrichment.md) finds a business's real online store,
the site can surface a "Shop Now" CTA — a small commerce affordance and an
affiliate/referral surface (see [the value model](../decisions/the-value-model.md)).
The hard part is not finding a link; it is not surfacing a BAD one.

## The confidence gate (the load-bearing part)

The original extraction (`_extractExternalStores`) host-matched ANY marketplace
link, so roughly half the captured "stores" were not the business's own store at
all — bare `amazon.com` homepages, search pages, "recommended" rails. Sending a
visitor there is a dead end and erodes trust ([no dark patterns](../decisions/no-dark-patterns-ethos.md),
[the mission](../decisions/the-mission.md)). The fix: `storeLinkConfidence()` scores
a candidate, an admin audit panel surfaces low-confidence ones, and a **render-time
gate** hides anything that does not clear the bar. A CTA only shows when we are
confident it is the business's real store.

## Status

The confidence scoring + audit panel + render gate shipped; extraction hardening and
a backfill of already-captured links remain. This is the same discipline as the
[store-link confidence in Money Clouds](money-clouds.md) affiliate feeds and the
"Shop Now" on business sites: better to show nothing than to show a wrong link.
See [[project_enrichment_store_links]].

---

# Events to machines — a live event is only half published until a machine can read it

`systems/events-to-machines.md` · type: System · tags: events, schema-org, ai-discoverability, feeds, gatherings, timezone, aeo

> The pipeline that takes real events from public feeds onto a Doozer page AND emits them as schema.org Event, plus the timezone rule that makes the data correct. The foundation for gohe.re Gatherings.

# Events to machines

**The operator's goal, stated 2026-08-23:** Doozer sites should be *perfectly tuned for AI
agents to find, understand and reference*, as well as for search engines and people.

That goal exposed a gap the day after events started working. Lodge and venue pages were
carrying real, current events — a visitor could read "Friday Fish Fry, Aug 28, 4:00 PM"
perfectly well. The page emitted `LocalBusiness` and `FAQPage` and **no `Event` at all**.
So an AI agent, a search engine, or any structured-data consumer had no idea the event
existed.

> **A live event is only half published until a machine can reference it.**

## The pipeline

    public feed  ->  bound by Magellan  ->  stored as facts  ->  rendered for humans
                                                             ->  emitted as schema.org Event

Sources today (all keyless, all legitimate):

| source | Magellan service | what it yields |
|---|---|---|
| public Google Calendar embed | `gcal` | full event feed with recurrence ([[magellan-and-feeds]]) |
| Ticketmaster / Marquee | `ticketmaster` | ticketed shows at known venues |
| schema.org on the business's own site | — | `Event` where present (rare: ~0 in sample) |

`businessUpcomingEvents()` (`business-events.php`) is the single read path: gcal first
(a business's own calendar outranks anything we infer), then Marquee. **One events
surface, many sources** — adding a feed must never add a second events UI.

`buildEventsJsonLd()` (`schema-org.php`) emits from **the same call the page renders**, so
the structured data can never drift from what a human sees.

## THE TIMEZONE RULE (this is the correctness problem, not a detail)

`startDate` is only meaningful with a real UTC offset, and **a wrong offset is worse than
none — it moves the event.**

- Rows that carry the event's own zone (`tz`, from the calendar) get a true offset:
  `2026-08-23T13:00:00-07:00` for a San Diego lodge, generated on an Eastern server.
- Rows with no zone emit a **local date-time with no offset** — valid ISO 8601, honest
  about what we know. We do **not** stamp the server's zone onto a venue three time zones
  away.
- All-day events emit a date only.
- Anything already past is dropped, never advertised.

This is the same failure that once displayed a San Diego lodge's Friday dinner as Saturday
midnight. It is not to be reintroduced through the structured-data path.

## Where this is going

Every public gathering at a real place is a candidate for a **gohe.re Gathering** (QR,
attendees, media, memories, chat, find-each-other map), and later a **wuz.ai** memory
layer. Both need each event to be an addressable, machine-readable entity first — which is
what this node is the foundation for. Event categories worth ingesting as feeds appear:
live music, karaoke, trivia, bingo, tricky trays, fundraisers, bike nights, car shows,
cruise nights, meetups, showtimes.

**Facebook is not a source.** Page events cannot be read: direct fetch and mbasic both
return HTTP 400, a reader returns a login wall, and the Graph API Page `/events` edge was
deprecated in 2018. Bind `facebook` for identity only, never scrape. Do not re-litigate.

Related: [[magellan-and-feeds]], [[event-feeds]], [[ai-discoverability]],
[[gatherings-and-social]], [[the-observed-world]].

---

# Fact-Guard (the accuracy net)

`systems/fact-guard.md` · type: System · tags: fact-guard, accuracy, ai, content, healing, quality

> A conservative healer that catches and corrects factual drift in AI-generated content (e.g. lodge/post identity numbers) at both the DB boundary and render.

# Fact-Guard

Fact-Guard is a safety net for the one thing AI content must never get wrong:
**verifiable facts.** A tasteful site that states the wrong lodge number or a
mangled name is worse than a plain one — it breaks trust, which is the top of the
[quality bar](../decisions/the-doozer-story.md).

## What it does

`factGuardHeal($content, $biz, $opts)` (`includes/fact-guard.php`) runs over
generated content and conservatively corrects factual drift:

- `factGuardDeriveIdentity($name)` parses a business name for a fraternal/civic
  identity + number (Lodge / Post / Chapter / Council / Aerie / ... No. NNNN).
- `factGuardFixIdentityNumbers($data, $keyword, $number)` repairs a wrong number
  ONLY when a real separator is present (`No.` / `#` / `Number`), so it never
  invents or over-corrects.

It is **conservative by design** — it fixes provable drift and leaves everything
else alone. It runs at two boundaries: at generation
(`factGuardHeal(...)` after decode in [content generation](content-generation.md))
and at the DB boundary (hooked into `saveGeneratedContent()`), so both fresh
builds and re-saves are guarded.

## Why it exists

The [Elks fleet](elks-root-theme.md) surfaced it: an AI given a lodge name would
occasionally restate the lodge number wrong. Fact-Guard is the general pattern —
where a fact is derivable from hard input (the name, the Places data), verify the
output against it. It complements the [GIGO principle](location-data-enrichment.md):
GIGO says better input makes better voice; Fact-Guard says never let good input get
corrupted on the way out. Extend it as new verifiable fact classes appear.

## Prevention beats correction: feed the facts into generation

Fact-Guard is the net, not the first line. The better fix for most inaccuracies is
to hand the generator the fact so it never invents a wrong one — no per-fact trap.
Example (v2.2.330): an AI hero claimed a barbershop was open "Six Days" while its
hours showed closed Sunday AND Monday (open 5). Root cause: the content-generation
context (`ai-content.php`) fed name/address/rating/reviews but NOT the hours. Fix:
inject the real `weekday_text` plus a computed "Open N days a week (closed …)" line.
The model can't invent a schedule it's been handed. Operator rule: prefer accuracy
by GIVING the model the fact over trapping for a specific mistake — same spirit as
the [muse custom_css prompt fix](free-tier-render-gotchas.md). Fact-Guard stays for
the classes a prompt can't reliably prevent (e.g. an identity number the model
mangles even when told).

---

# Fact-guard — org identity numbers, and the hole a conservative regex left

`systems/fact-guard-identity-numbers.md` · type: System · tags: accuracy, fact-guard, elks, content-generation, trust

> How the accuracy net corrects a wrong Lodge/Post number, why the safe-looking regex silently no-opped, and why over-correcting it is worse than the original bug.

# Fact-guard — org identity numbers

`includes/fact-guard.php` is the network accuracy net: it overwrites AI drift on facts we
hold canonically. Rule 1 is the **organization identity number** (Elks/Moose "Lodge",
VFW/American Legion "Post", Eagles "Aerie", K of C "Council"). Two hooks, zero API cost:
a render-layer heal in `business-site.php` and a DB-boundary heal in
`saveGeneratedContent()`. Repair tool: `dz factguard` (audit / per-business decontaminate /
`heal=stored`).

## The lesson: a conservative regex can no-op silently and still look right

Shipped v2.2.318, the corrector required a `No.` / `Number` / `#` separator between the
keyword and the digits, deliberately, so ordinary prose ("post 2020 renovation") could never
be mangled. It was correct and it was tested. But the hallucination in the wild is uniformly
the **bare** form: *"Elks Lodge 339 in Plentywood"*. The heal matched nothing, changed
nothing, threw nothing, and read as working for two months (operator note #18, July 20 2026).

Two things compounded it:

- Both `factGuardHeal()` call sites passed **no `$opts`**, so the number was derived from
  the business NAME. Many lodges are scraped as plain "Elks Lodge" or "Elk's Club" with no
  number at all, so the heal was a guaranteed no-op for exactly the sites that needed it.
- The generator was never told the number. `businesses` has **no domain column** and both
  generation entry points load the row with a bare `SELECT *`, so the subdomain — the one
  identity fact we MINT rather than scrape — was out of scope in `buildBusinessContext()`.
  Meanwhile the prose brief asks for "a concrete fact: founding/charter year". The prompt
  rewarded inventing precisely the thing nothing supplied.

**Takeaway: a healer that cannot report what it changed is indistinguishable from a healer
that does nothing.** `dz factguard heal=stored` now emits a real before/after per row.

## The harder lesson: over-correction is worse than the original bug

The first cut of the bare-form rule rewrote every lodge number in the content to ours. On
live pages that produced:

- `Freemasons of Newark Lodge #83` → `#1249` (a Masonic lodge sharing the building)
- `a party of six from Lodge 401` → `Lodge 1249`
- `close relationship with Dallas Elks Lodge 71` → `Dallas Elks Lodge 2485`

Each trades one wrong fact for a **false claim about a different real organization**.
Enumerating "foreign cues" kept losing, so the rule was inverted: only correct a mention
that is positively OURS. A mention is skipped when preceded by a foreign-order or
other-lodge cue, by `from`, or by a **place name that is not one of our own city tokens**.

> Leaving another lodge's number intact is not a false claim about us. Changing it is a
> false claim about them. The tie always breaks toward leaving it.

The cost of that conservatism is real and accepted: a handful of pages simply never state
their own number in prose. That is a gap, not a lie.

## Two traps worth remembering

- **Compare numerically.** The Order zero-pads: `?LodgeNumber=0031` is lodge 31. A string
  compare flagged ~130 correct rows as contaminated and would have rewritten them all.
- **Compare decoded structures, not JSON.** Re-encoding `ai_generated_content` differs in
  slash/unicode escaping even when nothing changed, which reported ~170 phantom rewrites.
- **Single-digit charter numbers are real** (Louisville #8, St. Louis #9). A `\d{2,6}` floor
  silently skipped them.

## The root cause was DATA, not tone

Plentywood (business 1658) had the wrong lodge in its own record: `website` pointing at
`LodgeNumber=339`, `email` `secretary@alleghenyelks.org`, a Pittsburgh `412` phone in
`deep_research`. Enrichment scraped the wrong lodge; the generator faithfully wrote what it
was fed. Signal in, signal out. Seven elks businesses had a `LodgeNumber=` URL contradicting
their subdomain, two of which still got the right number in content, so the bad-website
problem is slightly wider than the bad-content one.

**That leaked ownership, which mattered more than the copy.** With `admin_email` NULL, the
scraped `email` IS the implicit owner ([[project_implicit_verification]]), so an officer of
the Pittsburgh lodge could have signed in by magic link and managed a Montana lodge's site.
Three businesses were in that state. A scrape of the wrong entity is untrustworthy
field-by-field, so decontamination drops the derived scrape wholesale
(`deep_research.contact_email`, `additional_phones`, enrichment provenance) and re-points
`website` at the lodge's own canonical page. Google's geo-verified phone and address are
never touched, and an email naming our own lodge or city is KEPT.

Verification method that settled every case: fetch `elks.org/lodges/home.cfm?LodgeNumber=N`
and compare its city/ZIP to the business's `formatted_address`. All seven subdomains proved
correct at ZIP level; the stored websites were the wrong ones.

## The number is the least of what a wrong-lodge scrape carries

After the websites were corrected, Flora IL still linked to **Carmichael CA's
Facebook** and rendered **Carmichael's photo as its hero**, because the first pass
cleared four keys and left `social_media`, `images`, `logo_url`. Camillus NY had a
correct website but only **Chicago lodge 2367's** images. Wrong photo and wrong
social link are worse than a wrong number, and neither is reachable by a text healer.

Detection took three tries; the failures are the useful part:

- A loose `~elks(\d+)~` read Facebook page ids and **image dimensions**
  (`WoburnStonehamElks-150x150.png` → "150") as charter numbers, condemning 5 clean rows.
- Scoping to elks.org URLs still flagged lodges that legitimately embed another
  lodge's asset (a state president's photo, a shared bingo graphic).
- **The rule that holds:** a foreign lodge number in an elks.org asset path is damning
  only when **none** of the assets are ours.
- `json_encode` writes `\/`, so a char class stopping at the backslash truncates the
  URL to `www.elks.org` and hides the path carrying the number. Un-escape first.

`purge=1` forces the purge for a hand-verified case, because repairing the website
first removes the signal the detector keys on.

## Adjacent defect, deliberately not fixed here

Two businesses (685, 1601) carry a **spurious second elks domain** whose lodge number
differs from the one their content correctly states. That is a domain-mapping problem, not
an accuracy one; `dz domdupes disable=<domain_id>` is the tool. `dz factguard` refuses any
business whose elks domains disagree, so it cannot "fix" one of these by corrupting good data.

See also: [[elks-root-theme]], [[content-generation]], [[source-of-truth-cache]], [[dev-api]].

---

# Gatherings + Social (events, check-ins, the human layer)

`systems/gatherings-and-social.md` · type: System · tags: gatherings, events, checkins, gohere, wuzai, social, roadmap

> The roadmap surfaces that add real-time human signal — events/gatherings (GoHe.re), check-ins (wuz.ai), and how they enrich every business site.

# Gatherings + Social

The strongest content lever for a business site is not better prose — it is **real
human signal**: what is happening there, who was there, what they thought. This is
the [enrichment](location-data-enrichment.md) direction made social, and mostly
roadmap (some live).

## Events (live pieces)

`marquee_events` (Ticketmaster via `marquee-engine.php`) powers vertical
directories like [Live Music Tonight](livemusictonight.md) and the Events view in
the [directory framework](directory-framework.md). An `lmt_interest` demand graph
records view / interested / ticket-click signals, feeding an admin "Live Music
Demand" build loop: real demand decides which venue sites to build next.

## GoHe.re — gatherings (roadmap)

The events/gatherings root. The **Events Waterfall** strategy builds backwards:
ticketed events → venue sites → calendars → Global Gatherings. "Instant
Gatherings" and "Wuz-for-One" let anyone create a gathering for any place + time.
This is the surface where user-supplied events, comments, and photos flow into the
**true voice** of each location.

## Check-ins — wuz.ai (roadmap, flaky-live)

Check-in / "Wuz" toolbar signals who is where, now. `user_hotspots`
([user hotspots](user-hotspots.md)) already derive a coarse home centroid for
signed-in users to enable proximity features.

## Why it matters

Per the [GIGO principle](location-data-enrichment.md), a site sounds like itself
only when fed real signal. Events, check-ins, and gathering photos/comments are
that signal, and crucially they feed **every** surface on the shared
[data spine](the-network.md) — the business site, the [Clouds](clouds.md), the
vertical directories, and the [Atlas](atlas.md) emission. One person RSVPing to a
show enriches the venue's site, its directory listing, and what AIs know about it.
That is [the flywheel](the-flywheel.md) reaching into the physical world.

---

# Hot Leads — the internal Doozer-sales CRM

`systems/doozer-prospects.md` · type: System · tags: prospects, hot-leads, crm, doozer-plus, awareness, monetization, leadbuilder, machi

> Businesses are OUR prospects. Doozer-awareness scoring (claimed + postcard engagement + leads we deliver + live site) ranks who is warm enough to pitch Doozer+. The Internal Affairs Division of LeadBuilder.

# Hot Leads — the internal Doozer-sales CRM

`includes/doozer-prospects.php`. Shipped v2.2.416-417. The strategic payoff of all the
tracking: it turns postcards + claims + the leads-we-deliver into a ranked list of who is
warm enough to close on Doozer+. Jon's flywheel, made visible: they know us → we send them
value (leads) → we make the right offer → free becomes Doozer+.

## The awareness score (conversion readiness, 0-100)

`businessDoozerAwareness($bizIdOrRow)` → score + component breakdown + status. Weights live in
`doozerAwarenessWeights()` (one place to tune):

| signal | pts | why |
|---|---|---|
| claimed the listing (`admin_email` set) | +40 | they acted, they know us — biggest |
| scanned our postcard (`thanksio_events` scan) | +18 | engaged with our mail |
| postcard delivered | +6 | mail physically reached them |
| each postcard sent (`thanksio_sends`) | +3 (cap 9) | touches |
| **each lead WE delivered** (`nbmn_lead_requests.routed_to_business_id`) | +12 (cap 40) | we give value — the strongest "we have something to offer" |
| live Doozer site | +4 | inventory exists |

Status: paying owner (`businessIsPremiumOwned`) → `customer` (not a lead — a save/expand
target). Else `hot` (≥55) / `warm` (≥25) / `cold`.

## Hot Leads list + admin

`doozerHotLeads($opts)` — candidate set = businesses we've TOUCHED (mailed OR delivered a lead
OR claimed); never-touched businesses are not leads. Scored, ranked hottest first, customers
excluded by default. `$opts`: niche, state, limit, min_score, include_customers. Admin at
**Intelligence → Hot Leads (Doozer+)** (`admin-prospects.php`): score bars, the "why"
breakdown, and Postcard / Manage quick actions. CLI `dz prospects [do=hot|score b=]`.

## Next best action + channel (v2.2.418)

`doozerProspectAction($row)` returns action + channel + urgency (now|soon|later) + reason.
The channel rule (Jon): getting leads + email → **email** the claim invite; getting leads +
**NO email** → **mail a postcard STAT** (these sort to the top — leads with no inbox = money on
the table); mailed already + still no email → **call** the phone; claimed + free → Doozer+ pitch
(email else mail). **SMS is OUT (regulatory).** Facebook Messenger via official page = future.
`doozerHotLeads` sorts urgency-first. `doozerProjectedClaim($row)` = projected claim % (heuristic
until real outcomes; getting-leads ×3.5, scanned ×2 over a ~4% cold baseline) — sharpens as
claim/convert rates by niche+market accrue (feed from the postcard Format-performance panel).

## The LeadBuilder pipe (v2.2.418)

`doozerProspectsPipeToLeadBuilder()` (admin "Sync to LeadBuilder"; `dz prospects do=pipe`)
upserts each prospect into **`lb_leads`** (in the LeadBuilder DB via `nbmnLeadGetDb`), source
`doozer_internal`, intent `doozer_plus_prospect`, under a synthetic "Doozer Internal Sales"
`lb_site`, one `lb_visitor` per business (reuses `nbmnLeadEnsureLbSite`/`Visitor`). `score_factors`
carries `doozer_business_id`, awareness, projected %, signals, and next_action/channel/urgency.
Idempotent (upsert by site+visitor+source). Internal prospects now live inside LeadBuilder proper.

**Distinct from Machi** (`machi.md`): Machi = OUTBOUND NEED ("who to mail next"); this =
CONVERSION READINESS + the exact next move. NEXT: campaign/niche/market response comparison to
auto-weight targeting ("mechanics claim 20% more → mail more mechanics"); FB Messenger channel;
cross-sell Keeper/agency to `customer`-status businesses.

Ties to [[project_monetization_plan]], [[the-observed-world]] CLAIM→EARN, the
[[feedback_right_offer_right_audience]] rule, and [ai-postcards](ai-postcards.md) tracking.

---

# IndexNow (instant crawl notification)

`systems/indexnow.md` · type: System · tags: seo, indexnow, bing, crawl, sitemap, quality-gate, indexing

> Push index-eligible Doozer URLs to Bing/Yandex the moment they cross the quality gate, so fresh and changed pages get crawled in hours instead of weeks. Bing-first hedge against the Google scaled-content classifier that cliffed elks.

# IndexNow (instant crawl notification)

IndexNow is a free "push" protocol: you notify participating search engines
(Bing, Yandex, Seznam, Naver) that a URL is new or changed, and they crawl on
demand instead of waiting for a sitemap re-fetch. **Google does not consume
IndexNow**, so this is a Bing-first play, and a deliberate hedge: elks.club
cliffed on Google's scaled-content classifier
([SEO cliff & recovery](../decisions/seo-cliff-recovery.md)), so diversifying the
traffic base onto Bing matters.

Shipped v2.2.359. `includes/indexnow.php`.

## How ownership works across 2,400+ domains

IndexNow verifies you own a URL by fetching a key file (`https://<host>/<key>.txt`)
**on the same host as each submitted URL**. Doozer already serves `robots.txt`
dynamically on every domain, so we do the same for the key file: `index.php`
matches `/<32-hex>.txt` and echoes the one shared key. That means a single key
(`platform_settings.indexnow.key`, generated on first use) verifies the entire
network, with no per-domain key management. The route is on the sessionless path
list so it never takes a session lock.

Because each Doozer business site is its own host (e.g. `lodge.elks.club`),
submissions are one host per request. Directory/root hosts with many URLs batch
into a single request (IndexNow allows one host, many URLs).

## The load-bearing guardrail

Submit **only index-eligible URLs** — the exact sitemap rule:
`domains.status='active' AND include_in_sitemap=1 AND
businesses.ai_generated_content IS NOT NULL`. Never push skeletons, `noindex`, or
thin pages. Pushing slop to a search engine re-teaches it that we emit slop,
which is the precise mistake that hurt elks. This reuses the existing quality
signal rather than inventing a new one — enrich-then-index, same as the
[observed-world flywheel](../decisions/the-observed-world.md).

## Triggers (both async, via the `indexnow_submit` job)

- **Content change:** `saveGeneratedContent()` (ai-content.php) enqueues after any
  write that leaves a business index-eligible. Covers fresh builds crossing the
  gate AND every regen (so a de-boilerplate fleet run doubles as a Bing recrawl
  signal, for free).
- **Manual include:** the admin "toggle sitemap" handler enqueues when a domain is
  flipped INTO the sitemap. Removal is a no-op (IndexNow has no de-index).

## Safety rails

Daily cap (`indexnow.daily_cap`, default 2000), short-window dedup (don't
re-hammer a URL pushed OK in the last hour), a self-creating `indexnow_submissions`
audit table, and loud logging on any 4xx/5xx (the elks credit-outage lesson:
never swallow a provider error). Zero API cost.

## Operating it (Dev API)

`dz indexnow` — status (key, enabled, cap, sent today, recent rows).
`dz indexnow test=<url>` — force one submit (bypasses the switch) to prove the
pipe. `dz indexnow enable=1|0` — master switch. `dz indexnow rotate=1` —
regenerate the key (invalidates the old key file). `dz indexnow run=backfill
root=<r>` — queue every currently-eligible URL for a root, chunked (e.g. backfill
elks.club to push the ~1,700 de-boilerplated pages at once).

Verify with Bing Webmaster Tools → IndexNow, which shows submissions landing.

---

# Lead Capture (the revenue pipeline)

`systems/lead-capture.md` · type: System · tags: leads, monetization, contact-form, leadbuilder, routing, value

> How a visitor's message or call becomes a routed, deliverable lead — the shared nbmn_lead_requests pipeline, auto-routing, no-email self-heal, and LeadBuilder handoff.

# Lead Capture

Leads are how Doozer turns free sites into revenue (see
[the value model](../decisions/the-value-model.md)). There is ONE lead pipeline;
every surface feeds it.

## The pipeline

`nbmn_lead_requests` is the single table. A lead enters from:
- **A business site's contact form** — `includes/contact-form.php`
  (`contactFormButton()` + `renderContactFormDialog()`), posting to
  `?e=nbmn_lead_request` with `lead_source=business_site` + `selected_business_id`.
- **A NearByMeNow directory CTA** — the "have a top-rated <niche> reach out" form.
- **Call / intent signals** — `includes/call-intent-categories.php`
  (`callIntentChipsForNiche()`) logs to `lead_intents` + bridges to LeadBuilder.

From the table: **admin triage** (`includes/nbmn-lead-requests.php`) →
**route-to-owner** (`nbmnLeadRouteToBusiness()`) → **LeadBuilder bridge**
(`leadCaptureBridgeToLeadBuilder()`). Routing to an owner emails them when a
contact address is on file.

## One business, one inbox (the canonical recipient)

`businessLeadRecipientEmail($business)` (nbmn-lead-requests.php) is THE single
place lead-recipient precedence is decided: the **claimed owner** (`admin_email`,
set when someone redeems a postcard code or matches by email) wins; the scraped
`businesses.email` is only the fallback for an unclaimed listing. One business,
one inbox — every lead from anywhere in the ecosystem (business site, NBMN, calls,
clouds) goes to that one email. Future agency/agent fan-out extends this one
function. The redeem + claim pages state the promise to the owner explicitly
("the email you claim with becomes where every lead arrives"). See
[[identity-and-auth]] (implicit verification: `admin_email` = the claim).

## Auto-route + the no-email plan (load-bearing)

A `business_site` lead with a `selected_business_id` **auto-routes on submit** —
the target is unambiguous (it's the site's own business). If the business has
**no email**, the lead is not dropped: Doozer fires a priority `basic_enrichment`
email-discovery scrape (the [implicit-verification](../decisions/implicit-verification.md)
self-healing backfill) and flags the lead `awaiting_email` to deliver once an
address lands. **A lead is never a dead end** — delivered if reachable, hunted for
a channel if not, and held meanwhile as a warm lead + an ownership hook. NBMN
directory leads still go through manual triage (which business a directory lead
belongs to is a judgment call).

## Spam + integrity

reCAPTCHA v3 (`includes/recaptcha.php`, invisible/score-based, **fail-open** so a
Google outage never drops a real lead) + a honeypot field + per-IP-hash rate
limiting. Keys live in `platform_settings` (secret-free in the graph).

## The gotcha that made it work

`getParam()` (`includes/api-endpoints.php`) read ONLY `$_GET` until v2.2.319, so
every `?e=` WRITE endpoint silently ignored POST-body params and the lead forms
captured nothing. It now reads `$_GET` then falls back to `$_POST`. Any new write
endpoint can accept a normal form POST. Filed under
[render gotchas](free-tier-render-gotchas.md): the write "succeeded," the row just
never arrived.

---

# LeadBuilder (the lead-intelligence platform)

`systems/leadbuilder.md` · type: System · tags: leadbuilder, leads, monetization, intelligence, tracking, value

> Where every captured lead, call intent, and cross-site referral flows to become revenue — the sibling product that turns Doozer traffic into qualified leads.

# LeadBuilder

`leadbuilder.io` is a BerrySmart sibling product: the **lead-intelligence
platform** that Doozer feeds. If [Lead Capture](lead-capture.md) is the plumbing,
LeadBuilder is the destination and the intelligence layer — where a visitor's
action on a business site becomes a qualified, attributable lead a business will
pay for. It is the primary way Doozer's traffic turns into revenue (see
[the value model](../decisions/the-value-model.md)).

## What flows in

Every demand signal on the network bridges to LeadBuilder:

- **Leads** — contact-form and NBMN lead-requests, routed via
  `nbmnLeadRouteToBusiness()` and piped in by `leadCaptureBridgeToLeadBuilder()`.
- **Call intent** — the category-aware Call popups (`lead_intents`) bridge here.
- **Visitor events** — `renderLeadBuilderSnippet()` mounts a lightweight tracker on
  business + directory pages, recording sessions and events to the `lb_*` tables
  (`lb_users`, `lb_sessions`, `lb_events`, `lb_follows`).
- **Cross-site referrals** — the [neighborhood direct links](shared-business-card.md)
  fire `sendBeacon` → the `track_network_referral` endpoint → a
  `click_network_referral` `lb_event` on the SOURCE site, so movement across the
  [network](the-network.md) is attributed, not lost.

## Why it matters

A free Doozer site creates presence; LeadBuilder captures the value that presence
generates and makes it measurable and sellable. `lb_users.tier` also carries the
entitlement that [Billing](billing.md) sells, so the same identity spans the free
site, the lead intelligence, and the paid tier. It is the revenue engine of
[the flywheel](the-flywheel.md), and the reason "we build a free site" is a
sustainable business rather than a cost center.

## Google Search Console ingestion (the demand data)

LeadBuilder also owns the network's **Google Search Console pipe** — the search
demand data that powers [Prospector](prospector.md)'s Cloud Radar and the
marketing signal miner. `includes/lb-gsc-client.php` is a dependency-free OAuth 2.0
+ Search Analytics client. Operator connects GSC properties (Admin → LeadBuilder →
GSC Properties); refresh tokens are stored encrypted in `lb_gsc_properties`.
`lbGscSyncProperty($propertyId, $daysBack)` pulls query + page aggregates into
`lb_gsc_query_daily` / `lb_gsc_page_daily`. GSC Search Analytics is **free** — no
per-call cost.

**Freshness is a background job.** `lbGscMaybeQueueRefresh()` runs from the
index.php shutdown tick (rate-limited 6h) and enqueues a per-property `gsc_refresh`
job when a property's `last_synced_at` goes stale (>20h); the job dispatcher runs
the sync. Built v2.2.364 — before that the scheduler was referenced in a comment
but never existed, so ingestion silently died after one manual sync (May 2026) and
Cloud Radar ranked on a frozen snapshot.

**Two durable gotchas (v2.2.364):** (1) refresh tokens are encrypted with
`ENCRYPTION_KEY`; a key rotation breaks decryption unless the decrypt tries the
legacy keys — `lbGscDecryptTokenEx()` now uses `doozerLegacyEncryptionKeys()` +
self-heals (re-encrypts with the current key). (2) If the Google OAuth **consent
screen is in "Testing" mode, refresh tokens expire after 7 days** → `invalid_grant`
→ dead pipe. Fix: publish the consent screen to production + reconnect the
properties. Dev API `gsc` (status / `sync` / `scan`) drives + diagnoses it all.

---

# Live Music Tonight (livemusictonight.com)

`systems/livemusictonight.md` · type: System · tags: vertical-directory, events, music, marquee, ticketmaster, interest-graph, prospector

> A demand-sensing live-music discovery directory built on the Vertical Directory engine, fed by the Ticketmaster/Marquee feed, that captures visitor interest as a graph to decide which venue sites to build next.

# Live Music Tonight

`livemusictonight.com` is a [Vertical Directory](vertical-directory.md) (one host row routed
by `includes/vd-router.php` → `includes/vertical-directory.php`). It is not a bespoke page —
it reuses the VD engine. It started empty because `vdFetchEvents()` read the unpopulated
`dynamic_section_cache`; it now sources real shows.

## Data resolution (here-and-now first)

Live music is a NOW thing, so the homepage leads with **location + Tonight** and lets the
visitor expand outward in time and space.

- **Located** (explicit `?lat&lng` from the geolocate button, or approximate from
  Cloudflare `CF-IPLatitude/Longitude`): live Ticketmaster geo feed via
  `marqueeTmFetch()` (gained `latlong`/`radius`/`unit`/`endDateTime`/`sort`), closest
  first. If nothing falls in the radius/window, an empty state nudges widening — it never
  silently dumps far-away national events on a located visitor.
- **No location**: stored `marquee_events`, then a live national fallback so the page is
  never blank. This is the browse mode where search/filter belongs (future).
- Radius filter: 5 / 10 / 25 / 50 / 100 miles, **default 5**. Auto-requests precise
  location on first load (remembers a denial via sessionStorage, no nagging).

## Time windows (30-day horizon)

`vdWindowEnd()` hard-caps everything at +30 days (no stray next-year listings). `Tonight`
is here-and-now; `This Week` / `This Month` are calendar-bounded; `Next 7 Days` /
`Next 30 Days` roll forward from today regardless of the date. The nav exposes the rolling
options via dropdowns on This Week / This Month.

## The interest graph (the point)

Every **Interested** and **Tickets** click records a demand signal to `lmt_interest`
(`?ajax=lmt_interest`, ip-hash rate-limited) keyed to **venue identity** + genre + area.
This is OUR derived demand signal, not a mirror of Ticketmaster's catalog (ToS-clean).
Admin → Marquee → "Live Music Demand" ranks venues by interest (interested×3 + ticket×5)
and hands a venue to the build flow. So real demand decides which venue sites
[Prospector](../glossary/prospector.md) and the [Cloud Factory](cloud-factory.md) build
next — the [flywheel](the-flywheel.md) applied to events.

## Ecosystem ties

- Cross-ecosystem identity: the [user avatar](shared-business-card.md) renders in the VD
  header (magic-link sign-in), so Interested can tie to a real person.
- Photos: TM event images render as card thumbnails (music-note SVG placeholder otherwise).
- Icons follow the house rule — crafted inline SVGs, never emoji/clipart.
- **Venue cross-link by ID:** Marquee stamps Ticketmaster's `tm_venue_id` onto the
  `businesses` row when it builds a venue site (`marqueeStampVenueId`), so `_vdLinkVenues`
  links a live event to its Doozer site deterministically (`businesses.tm_venue_id =
  event.tm_venue_id`) instead of fuzzy name matching. This is the durable handle a venue's
  own business site can use to point back at LMT or any vertical directory.
- **Interested on business sites:** each event on a venue's Doozer site (rendered by
  `renderBusinessEventsSection`) carries an Interested button (internal bookmark via
  `lmt_interest`) beside Tickets and + Calendar — the network keeps the save instead of
  pushing people to an external calendar.

## Marquee is venue discovery, not an event log

The Marquee admin (`marqueeRecentVenues`) surfaces ONE row per distinct venue (keyed by
`tm_venue_id`, else name+city+state), NEW venues (no Doozer site) first, built ones marked.
The product is *finding businesses to build* through the live event feed — not listing events.
Build acts on a representative event; the rest of the venue's shows become its site's calendar.

- Future: an "add all to calendar" iCal feed on my.doozer.cloud for a user's saved events +
  gatherings; clicking a show could spin up a [Gathering](../glossary/gathering.md) on GoHe.re
  (gated on the gatherings surfaces); non-ticketed/community shows union into the same feed.

---

# Magellan and the feed pond — bound IDs turn sites into living entities

`systems/magellan-and-feeds.md` · type: System · tags: magellan, feeds, identity, calendar, elks, accuracy, strategy

> The business_id_map spine (built v2.2.549) plus the feed adapters that hang off it. A bound external ID is what lets a generated page carry current, authoritative, self-maintained content instead of static prose.

# Magellan and the feed pond

**The operator direction (Aug 21 2026):** "Cross link as much as we can and turn feeds,
sites, and other directories into data feeds. Doozer sites need to be living entities."

## The spine (BUILT — `includes/magellan.php`, v2.2.549)

`business_id_map (business_id, service, external_id, source, confidence, verified_at)`,
unique per `(business_id, service)`, reverse-indexed on `(service, external_id)`. This is
the table specified in [magellan-id-mapping](../decisions/magellan-id-mapping.md) in June
2026 and finally built. `magellanBind/Id/Find/All/BackfillHot/Stats`; `dz magellan`.

Live coverage: **google 3,632 · elks_org 1,704 · gcal (growing) · ticketmaster 101.**

The hot columns (`businesses.google_place_id`, `businesses.tm_venue_id`) are NOT touched;
`magellanBackfillHot()` copies them IN so both views agree while callers migrate.

**The rule stays: join by ID, never by name+city+state.** Name matching guesses,
duplicates, and fails silently.

## Why a bound ID matters (the actual point)

A generated page describing a business is one of thousands and gets skipped. A page
carrying **what is on tonight** is the only page answering that question. A bound ID is
what makes the second thing possible, and it is also an accuracy engine: every feed is a
second opinion on the facts.

## Feed adapters

### `gcal` — public Google Calendar (`includes/feed-gcal.php`, GENERIC)

A public Google Calendar exposes iCal at a predictable URL with **no key and no quota**.
Any page embedding one names a live, self-maintained event feed.

    <iframe src="...calendar/embed?src=<id>">
      -> https://calendar.google.com/calendar/ical/<urlencoded id>/public/basic.ics

`gcalIdsFromHtml` (handles the `addr@gmail.com` and hex `@group.calendar.google.com`
forms, and `src` not being the first query param), `gcalFetchIcs`, `gcalParseUpcoming`,
`gcalSyncBusiness`. RFC 5545: line unfolding, `VALUE=DATE`, `TZID`, `EXDATE`, cancelled
events, recurrence for `FREQ=DAILY/WEEKLY/MONTHLY/YEARLY` with `INTERVAL`/`BYDAY`/`COUNT`/
`UNTIL`, hard-bounded. `BYSETPOS`/`BYWEEKNO` are NOT expanded (stated, not hidden).

**GOTCHA — timezone is a correctness bug, not cosmetics.** Store the instant AND the
calendar's `tz`, and format with `gcalLocalString()`. First pass showed San Diego's
"Friday Dinner" as Saturday 00:00 because correct UTC instants were formatted in the
server's zone. **Events belong to the place that holds them.**

**GOTCHA — the reader strips iframes.** Calendar discovery must fetch RAW HTML
(`elksOrgFetchHtml`), not `sourceFetchMarkdown()`. This is why the calendar page looked
empty and was nearly written off as unusable.

### `elks_org` — the BPOE registry (`includes/elks-lodge-contacts.php`)

Lodge number is in every elks.club subdomain (1,710/1,710), so the path is deterministic
and needs **no search API key**. `elks.org/robots.txt` is permissive (only three history
PDF paths disallowed). Per-lodge pages: `home` (address, meeting times, officers, official
site, Facebook), `lodgeFacilities` (social-quarter and hall hours — RICH, and exactly what
the real leads ask for), `lodgeNews`, `lodgeCalendar` (the Google embed), `contactUs`
(form URL — surfaced for a HUMAN, **never auto-submitted**).

Measured on a 24-lodge random sample: **25% list their own website, 20% Facebook only,
100% a verified address, 17% embed a calendar.**

**GOTCHA — parse the BODY SLICE only**, between `## <City>, ST Lodge No. <N>` and
`##### Navigation`. Outside it is elks.org chrome, which made the national vendor link
`elksbenefits.com` look like a lodge website on the first pass (a MapQuest directions link
did the same). Officers are PROSE ("The Exalted Ruler of the Lodge is X, and the Secretary
is Y"), not a labelled list.

## Rules every adapter honours

- **GEO-ANCHOR.** Feed addresses are REFERENCE only, never applied to the geo-verified
  location. Enrich the content, never the coordinates.
- **Facts, never prose.** Copying source prose is what caused the elks.club scaled-content
  cliff. Extract data; let the site render it.
- **No automated outbound.** Reading public pages is fine. Submitting a third party's
  contact form at scale is not, whatever the volume argument.
- **Dry-run by default**, `live=1` writes (the `dz harvest` convention).
- **Be a polite guest**: delay between fetches, cache hard.

## Next

Render the bound data on the site (facilities hours, upcoming events). The facts are
captured but the page does not use them yet — the same gap
[[project_foundation_sprint]] notes for `source_facts`. Then more services: the lodge's
own site, state associations, and other directories that publish structured member data.

Related: [[the-flywheel]], [[event-feeds]], [[money-clouds]], [[source-of-truth-cache]],
[[project_where_the_traffic_is]].

---

# Marketing (build in public)

`systems/marketing.md` · type: System · tags: marketing, build-in-public, composer, growth

> The composer that drafts social posts from real platform signals in the founder's voice, and the auto-draft review queue.

# Marketing (Build in Public)

Doozer markets itself the way it was built: **in public, from real signals, in the
founder's voice.** The composer drafts social posts (X) grounded in what actually
happened on the platform, never invented hype.

## Signal miners

`marketingMineChangelogVersions()` (parses `CHANGELOG.md`),
`marketingMineLbMetrics()` (network reach, businesses indexed, hot leads, GSC
impressions, weekly new sites), and `marketingMineRecentDiscoveries()` (new
domains + businesses in the last 30 days) turn platform reality into post material.
Metrics come from the same numbers that run the business, so posts are true.

## Composer + voice

`marketing_posts` holds drafts; `ai_prompts` rows (changelog / metric / discovery /
freeform) carry a @levelsio-style build-in-public voice with a **banned-phrase
list** baked in so drafts avoid AI-slop tells (and no em-dashes, per the
[no-slop quality bar](../decisions/the-doozer-story.md)). Each request drafts 3
variants; the operator picks one.

## Auto-draft loop (M4)

When a [Prospector](prospector.md) discovery becomes a live Doozer site,
`marketingDraftFromDiscovery()` fires automatically. All 3 variants persist as
draft rows tagged `auto_drafted: true`; the Marketing admin's Review queue groups
them by business, the operator picks one, siblings are trashed. So growth events
produce marketing material with no manual step, closing the
discovery → build → announce loop from [Prospector](prospector.md).

Marketing is the outward voice of [the flywheel](the-flywheel.md); the outward
*data* voice is the [Atlas](atlas.md).

---

# Money Clouds (affiliate vertical directories)

`systems/money-clouds.md` · type: System · tags: money-clouds, affiliate, monetization, vertical-directory, magellan, roadmap

> Generalizing the Live Music Tonight pattern — any niche with an affiliate feed becomes a revenue-share vertical directory, bound across APIs by Magellan.

# Money Clouds

The affiliate-revenue generalization of the [Live Music Tonight](livemusictonight.md)
pattern ("Make it Rain"). LMT proved the shape: a vertical directory backfilled
from a partner feed (Ticketmaster), rendered with structured data, monetized via an
affiliate program. **Money Clouds** apply that shape to ANY niche that has an
affiliate or revenue-share feed. Mostly roadmap; LMT is the live proof.

## How it works

- A niche + a partner feed → a vertical directory (like a themed [Cloud](clouds.md)),
  backfilled from the feed and surfaced through the shared
  [directory framework](directory-framework.md) + [business card](shared-business-card.md).
- The place is bound across APIs by [Magellan](../decisions/magellan-id-mapping.md):
  the Google place == the OpenTable place == the Hotels.com place, always by ID,
  never name-guessing. That is what lets one search resolve, and one directory
  backfill, from multiple paid feeds and monetize the same entity.
- Demand (search + intent, e.g. the LMT `lmt_interest` graph) picks which verticals
  and places to build, tying it to [Prospector](prospector.md) and
  [network growth](network-growth.md).

## Partner shape (verify ToS + program acceptance)

Fast breadth from **aggregator networks** (Travelpayouts, Skimlinks/Sovrn, Impact,
CJ, Rakuten, Awin) plus a few **direct** integrations for depth and better rates
(OpenTable, Booking.com/Expedia, Viator, SeatGeek, DoorDash). Each becomes a Money
Cloud. This is the revenue-diversifying arm of [the value model](../decisions/the-value-model.md)
and [monetization](billing.md), beyond leads and premium. Sequence after the core
infra settles; see [[project_search_to_site_money_clouds]] and
[the events waterfall](gatherings-and-social.md).

---

# NearByMeNow (the traffic + directory layer)

`systems/nearbymenow.md` · type: System · tags: nbmn, directory, traffic, discovery, referral-loop

> The public "what's near me" surface that indexes the whole fleet, drives visitors back to business sites, and captures demand as leads.

# NearByMeNow

`nearbymenow.com` (NBMN) is the **active side of the referral loop** in
[the network](the-network.md): a public directory that answers "what's near me,
open now, and good," indexes every business in the fleet, and sends visitors to
each business's own [generated site](architecture.md).

## What it does

- **Spatial discovery** — `nbmnFetchNearbyListings()` (`includes/nearbymenow-data.php`)
  runs a Haversine + bounding-box query over `businesses.lat`/`lng` (backfilled by
  `includes/geocode-backfill.php`) with niche/category filters. Cold-start city
  searches use the `cities` centroid table.
- **Visitor location** — `?e=geo` resolves location (browser → Cloudflare IP-geo
  headers → none); `?e=businesses_near` serves geo-sorted JSON.
- **Directory surfaces** — niche/category/city pages rendered through the shared
  [multi-view framework](directory-framework.md) (Grid/List/Map/Events) using the
  one [business card](shared-business-card.md).
- **Demand capture** — every niche directory page carries a lead CTA that feeds the
  shared [lead pipeline](lead-capture.md); call intent is logged per category.

## Phase 2/3 features (shipped)

- **Report-an-Issue** on every business site footer + a public
  `?page=about-listings` explainer of the [implicit-verification](../decisions/implicit-verification.md)
  model (the "no account" ownership ethos, with the `agencies@doozer.work` hook).
- **Lead-request capture** (`nbmn_lead_requests`) with allowlisted fields, phone
  digit validation, and per-IP-hash rate limiting.
- **Self-healing email backfill** — `domain_visit_fingerprints` counts repeat
  visits; a fingerprint seen enough times enqueues a harder email-discovery scrape,
  so an owner who "tried last week and it didn't work" finds it works today.

## Why it matters

NBMN is what makes the fleet more than a pile of pages: it turns 2,400+ isolated
sites into a searchable neighborhood, drives cross-site traffic (now via
[direct neighborhood links](../systems/shared-business-card.md), no two-click
detour), and converts browsing into [leads](lead-capture.md). It is the traffic
engine of [the flywheel](the-flywheel.md).

---

# Network Growth — "More places nearby" (build-on-tap, gated)

`systems/network-growth.md` · type: System · tags: growth, more-places-nearby, search-to-site, gate, prospector, roots

> How the network grows organically from real local intent — visitors spin up the next site — and the two per-root guardrails that keep that growth clean.

# Network Growth — "More places nearby"

The organic, demand-driven way the network indexes its next site. On every
[business site](architecture.md) (below "More from the neighborhood", which shows
EXISTING Doozer sites), a small section suggests up to 3 nearby same-niche places
that are NOT yet on Doozer, pulled from Google Places and deduped by place_id via
[Magellan](../decisions/magellan-id-mapping.md). One tap builds that place's site
and lands the visitor on it. A real visitor arriving from Google, with real local
intent, spins up the neighbor that wasn't there yet. This is the same widget NBMN
shows for thin category searches, brought to business sites.

## The flow

- Suggestions: `nbmnPlacesNotOnDoozer()` (`includes/nearbymenow-data.php`) — the only
  paid Places call is a cache MISS (cached 30 days per niche + geo-bucket, empties
  too). Rendered by `includes/places-nearby-suggest.php` (`renderPlacesNearbySuggest`),
  `!important`-hardened chrome (muse pages).
- Build: card click → `?e=nbmn_build_place` → `resolvePlaceToEcosystem(...)`
  ([the WHERE engine](../decisions/magellan-id-mapping.md)). Skeleton-first, content
  queued, noindex until quality-gated ([SEO discipline](free-tier-render-gotchas.md)).
  Hourly throttle `platform_settings.nbmn_build_max_per_hour`.
- Quality bar: `place_resolver_min_score` (default **40** — a 50+review / 4.3+ rating
  local business is real content, not slop; it shouldn't fail just for lacking a
  website, which is exactly who Doozer serves). Below-bar places DON'T build — the
  card opens a "contact anyway" popup (name, rating, click-to-call, directions). The
  internal score is NEVER shown to a visitor. Rejected place_ids (logged score < the
  bar in `place_resolutions`) are dropped from future suggestions.
- Self-dedup: a just-built place is re-checked against `businesses` at render so a
  new site never suggests itself.

## The two per-root guardrails (both opt-in, both in `root_domains.custom_settings`)

1. **Service area** — a regional root's geographic reach: `service_area` =
   `{radius_mi, optional lat/lng}` (center auto-derives from the root's business
   centroid). `rootServiceArea()` (`geographic-helpers.php`). Filters suggestions AND
   hard-gates the build, so westmilford.biz only ever suggests/builds within 12 mi.
   **Distance is the limit, full stop (v2.2.394 fix).** Two bugs were closed:
   (a) the build gate lived only on the unknown-place path, so a business ALREADY in
   the ecosystem (e.g. a Paterson bakery, 18 mi out) skipped it and built a sibling
   from "More places nearby." The gate is now a shared helper
   (`placeResolverServiceAreaBlock` in `place-resolver.php`) called on EVERY build
   path, including reused/known businesses. (b) The suggestion filter's county
   "edge tiebreaker" used to rescue any same-county place up to 2x the radius —
   Paterson is the same county as West Milford, so it showed at 16 mi. Suggestions
   are now strict to the radius too (any state in range, NO county rescue past it),
   so the list agrees with the build gate and never teases an unbuildable site.
   NOTE: the admin edit still uses raw lat/lng fields (place-picker is a TODO); the
   values store correctly into `service_area`, so this was never the leak.
2. **Expansion target** — WHERE a build lands: `expand_target` = `self` (a subdomain
   on THIS root, open regional directories only) or `external` (**DEFAULT** — the
   neutral doozer.site). `rootExpandTarget()`. The card's `data-root` carries the
   resolved target. SAFE DEFAULT is external, so a curated/branded root
   (elks.club, vsso.net) can NEVER sprout a random-business subdomain — those build
   on doozer.site. westmilford.biz is set to `self`.

Both are managed per root in Admin → Root Domains. This is the intake valve of
[the flywheel](the-flywheel.md), refined so growth is local, intent-driven, and
never pollutes a branded network. See [[project_search_to_site_money_clouds]] and
[NearByMeNow](nearbymenow.md).

---

# Niche matching and live discovery

`systems/niche-matching-and-discovery.md` · type: system · tags: nearbymenow, search, places, niche, discovery

# Niche matching and live discovery

How NearByMeNow decides "does this business belong on the <niche> page?" and
"what is actually near this person right now?". Three bugs lived here at once,
all invisible because each one only ever made results *smaller*, never wrong.

## The rule: our tag OR Google's types

A business carries `businesses.primary_niche` (our single-value guess) and
`businesses.types` (Google's array). Niche pages matched only the first. That is
one guess per business against ~90 niches, so most businesses were unreachable
from the page that describes them.

Measured 2026-08-23 on the live network:

| niche page | matched by primary_niche | also tagged by Google |
|---|---|---|
| restaurants | 82 | +361 |
| bars | 59 | +191 |
| cafes | 9 | +56 |
| hair salons | 14 | +19 |

Use **`nbmnNicheMatchClause($nicheField, $typesField, $niche, $params, $db)`**,
never `nbmnNicheInClause` directly, for anything a visitor browses. It returns
`(primary_niche IN (...) OR JSON_CONTAINS(types, ...) OR ...)`.

**Two rules that keep it honest:**

1. `establishment` and `point_of_interest` are excluded from the types side.
   Google stamps them on 3,393 of 3,635 businesses, so including them makes
   every niche universal.
2. Exact `primary_niche` matches rank FIRST (`$nichePrecision` in
   `nbmnFetchListings`). Google tags a bowling alley `restaurant` because it
   serves food; that is a fine page-3 result and a bad page-1 result.

MariaDB 10.6 - no `JSON_OVERLAPS`. Use OR'd `JSON_CONTAINS(f, JSON_QUOTE(:x), '$')`.

## A niche slug is NOT a Google place type

`hair-salons` is not `hair_salon`. `auto-repair` is not `car_repair`.
`plumbers` is not `plumber`. `restaurants` is not `restaurant`.

Stripping punctuation out of a URL slug does not produce a place type, and
Google silently ignores a type it does not recognise - the search still returns
200 with generic nearby establishments, so the bug looks like "discovery is
mediocre" rather than "discovery was never asked the question".

**Always resolve through `niche_mappings`** via `nbmnNicheTypeValues($niche)`.
When a niche maps to several types, prefer the one matching the slug's singular
(`hair-salons` -> `hair_salon`, not the broader `hair_care`).

## The thing you asked for is never junk

`nbmnJunkTypes()` keeps the *untyped* "who else is on this street" widget free
of ATMs, bus stops and gas stations. It contains `car_wash`, `dentist`,
`doctor`, `pharmacy`, `hospital`, `bank`, `hotel`, `insurance_agency`,
`real_estate_agency`, `storage`, `funeral_home`.

It was applied to explicit searches too. A visitor on the car-washes page
pressed "Show what's near me", got a correctly typed Google search for car
washes, and had every result discarded for being a car wash. Car washes and
dentists returned zero cards, every time, for as long as the feature existed.

**In `nbmnNearbyDiscover`, the junk filter runs only when `$type === ''`.** With
an explicit type Google has already constrained the set; filtering again can
only subtract the answer.

## Why all three hid for so long

None of them threw. Each one returned a smaller, plausible result set, and a
short list of local businesses looks exactly like a short list of local
businesses. The symptom was recorded for months as "thin inventory" and "dead-end
niches" - a data problem - when all three were query problems. **When a surface
looks under-stocked, check what the query is actually asking before concluding
the shelf is empty.**


## The audit (2026-08-24) and what it added

Method: for every path a visitor can take to find a business, compute what the
query SHOULD return and compare to what it DOES. Four more defects, same
signature - all subtractive, none threw.

### `niche_mappings.google_type` was never validated

It is the question every discovery call asks Google. Some values are not Google
types. Google answers 400 INVALID_ARGUMENT, callers turn that into `[]`, and the
niche reads "nothing near you" forever while billing for each attempt.

Rejections now land in **`places_bad_types`**; **`dz nichetypes`** reports them
with the niches affected, and `fix=1 live=1` applies renames. It applies ONLY
renames Google itself rejected - inventing a plausible type would just create a
new silent zero. `roofer` -> `roofing_contractor` took that niche from 0 results
to 18.

### A failed call cached its own emptiness for 120 days

An errored lookup and an empty neighbourhood both arrive as `[]`. Both were
cached. One bad request could pin a niche to "nothing here" for four months
after the cause was fixed. `prDiscoveryLastCallFailed()` separates them; only a
real ZERO_RESULTS is remembered. **Never cache a failure as an answer.**

### "Best match" and "Nearest first" were the same query

The route forced `sort=closest` whenever an origin was known. Two labels, one
order. Best match is now a genuine blend - quality decayed by distance,
`EXP(-d/8)` - which is what a person means by "best" in a local directory.
Related: the sort whitelist in the route silently dropped any value not in
`[closest, rating, reviews]`, so adding a sort to the UI alone does nothing.

### Structural findings (reported, not changed)

- `include_in_sitemap` gates BOTH the sitemap and the directory listing query.
  "Do not submit this to Google" and "do not show this to a visitor browsing
  the directory" are different questions sharing one flag. 662 enriched
  par-create skeletons are invisible because of it.
- **57% of listable businesses appear on no niche page**, dominated by 1,330
  `association_or_organization` - the lodges, which drive 68% of human traffic
  and have no directory category at all. Venue types are unmapped too.

## The rule this all points at

Every defect here returned a smaller, plausible answer. A short list of local
businesses looks exactly like a short list of local businesses, so none of them
could be found by using the site - only by counting. **Any query that can return
fewer results than it should needs a way to say so out loud**: record the
rejection, flag the fallback, distinguish the error from the empty.

---

# Per-Location Data Enrichment (the true voice of a place)

`systems/location-data-enrichment.md` · type: System · tags: ai, content, enrichment, gatherings, checkins, events, pipeline, gigo

> Site voice is signal-in, signal-out. Every per-location signal (reviews now; events, gatherings, check-ins, comments, photos coming) enriches the location site, the database, AND every downstream consumer of the Doozer Data pipeline.

# Per-Location Data Enrichment

The durable principle behind the [content generation pipeline](content-generation.md):
**a site's voice is the sum of its input signal.** There is no house "voice" to author
and no per-vertical "voice" to force. The generator distills whatever real signal a
place carries, so the quality of a site is the quality of its data. Signal in, signal
out. (Operators sometimes say garbage in, garbage out; same law.)

## Proof: the Elks lodges write themselves differently

Every `*.elks.club` lodge runs the identical pipeline and the identical bespoke
[elks theme](free-tier-render-gotchas.md). Yet their heroes diverge completely, because
their reviews and place data diverge (surveyed July 2026):

- Golden CO #2740, West Milford NJ #2236 -> "...Cold Beer..." (their reviewers talk
  about the bar; Golden is literally Coors country).
- Lynnwood WA #2171 -> "Brotherhood, Service, and Community Since Day One".
- Bloomington IN #446 -> "120 Years of Brotherhood, Service, and Community".
- Alameda CA #1015 -> "Alameda's Hidden Gem Since 1895".
- Summit County CO #2561 -> "Brotherhood, Community, and Mountain Tradition".

An Elks lodge is its own animal: some are inner-city halls, some are golf courses or
country clubs. A lodge whose members and reviewers talk about veterans and scholarships
gets a service-and-heritage site; a lodge that reads like a good neighborhood bar gets a
bar site. That is correct behavior, not a defect. The lever for a thin site is never a
forced tone. It is **more and better input signal.**

## Where the richer signal is coming from (this is planned, not built)

Today the dominant signal is scraped Google reviews plus place data. The roadmap adds
first-party, location-owned signal, each of which sharpens the true voice of a place:

- **Events** for physical locations, scraped / sourced / pulled (ties to the
  [Live Music / marquee](livemusictonight.md) work and the events waterfall).
- **Gatherings** at the location (GoHe.re) and their attendee-supplied **comments and
  photos** — the warmest, truest signal a place can give off.
- **Check-ins** (Wuz) — presence and rhythm: who shows up, when, how often.

As those land, the generator (and any re-enrichment pass) has real, current, member-
supplied material instead of only third-party reviews. A community lodge stops sounding
like a bar because the community finally speaks for it.

## One input, many consumers: the Doozer Data pipeline

Load-bearing: **all data provided per location enriches more than that location's own
site.** A single enriched location record flows to:

1. the **location site** itself (its prose, sections, and voice),
2. the **database** (the canonical business record, keyed by identity, see the
   [Magellan ID mapping](../decisions/magellan-id-mapping.md) spine), and
3. every other surface that reads the pipeline: **vertical directories**
   (e.g. livemusictonight.com), **[clouds](../glossary/cloud.md)**, the
   [shared business card](shared-business-card.md), and the outward
   [Atlas](the-flywheel.md) emission.

So enriching one place is never local. It compounds across the network. This is the
enrichment half of [the flywheel](the-flywheel.md): better signal -> better site ->
more engagement (check-ins, gatherings, photos) -> more signal.

## Practical consequence for content work

Before "fixing" a site's copy, ask what signal it lacks, not what tone to impose. The
right move is usually to feed the location record (events, a gathering, real photos,
better reviews), then regenerate, rather than to hand-author voice the data cannot
support.

---

# Per-User Location Hotspots

`systems/user-hotspots.md` · type: System · tags: personalization, geo, privacy, digest, livemusictonight

> A derived projection of a signed-in user's primary/secondary location centroids (home/work) from their own signals, used to personalize near-me defaults, reminders, and the Digest.

# Per-User Location Hotspots

`includes/user-hotspots.php` derives a signed-in user's **primary** and **secondary**
location centroids (usually home and work) plus a typical **radius**, from the user's OWN
signals. It is a derived projection cached in `user_hotspots` (migration
`sprint_user_hotspots.sql`), recomputed lazily — never a track log.

## How it works

- **Signals** (`userHotspotPoints`): check-ins (weight 3, the strongest "you were here"),
  where the user searched on [Live Music Tonight](livemusictonight.md) (`lmt_interest`
  area coords), and the coords of businesses they saved (`lb_follows`).
- **Derivation** (`userComputeHotspots`): grid-bucket points at ~10 miles, rank by density,
  take the top one or two clusters (the second only if >=15mi from the first) as centroids;
  the typical radius is the 75th-percentile spread, snapped to 5/10/25/50/100.
- **Read** (`userGetHotspots`): returns the cached row, recomputing if older than 7 days or
  missing. `userForgetHotspots` clears it; `userHotspotLabel` names the nearest city.

## What it powers

- **Near-me defaults** — when a signed-in visitor has no live location (geolocation denied,
  no Cloudflare geo header), LMT centers on the home hotspot and defaults the radius to the
  user's typical distance. Precise geolocation still wins.
- **my.doozer.cloud "Your spots"** — shown in plain language with a Forget control.
- Future: reminder targeting (an interested artist returning within the user's radius) and
  Digest "near your home / near your work" blocks.

## Guardrail

Signed-in only; derived from the user's own usage; coarse (centroids + radius, not a path);
visible and clearable on my.doozer.cloud. A convenience that remembers where you are, not
surveillance. See [no dark patterns](../decisions/no-dark-patterns-ethos.md).

---

# Postcard targeting (the Postcard Queue)

`systems/postcard-targeting.md` · type: System · tags: postcards, claim, targeting, growth

> Who deserves a paid claim postcard — exclude committee-orgs and franchises, rank owner-operated marketing-spenders.

# Postcard targeting (the Postcard Queue)

Postcards cost money, so we spend them on **owner-operated businesses that invest in their
presence** (advertising / marketing / a website) and will act on a card by claiming.
`includes/postcard-targeting.php`; tool `dz pcqueue state= city= limit= site=`.

## The load-bearing part is the EXCLUSION

Data (2026-07): of ~2,657 live businesses, ~86% have a website, but **~65% are committee-led
orgs** (`association_or_organization` = 1,343, plus lodge/VFW/church/Rotary/township name
patterns). A blind run would mostly hit Elks lodges. `postcardCandidates()` excludes:

- **Committee/civic/worship/education/corporate-venue niches** (`postcardExcludedNiches`) —
  associations, nonprofits, churches, government, schools, plus corporate venues (arenas,
  theaters, bowling, malls) that came in via the Live-Music/Marquee side.
- **Name patterns** (`postcardExcludeNameRegex`) — Lodge / VFW / Legion / Elks / Church /
  Association / Township / "Post 123" …
- **National franchises** (`postcardFranchiseRegex`) — corporate owns the web, they won't claim.
- **Already-claimed** (`admin_email` set).

Excluded orgs stay fully claimable (on-site nudge + email); they just don't burn a postcard.

## The ranking

`postcardNicheScore` weights marketing-spend propensity: high (3) = home services / food /
personal care / dental-medical / auto / legal-financial / fitness; mid (2) = general retail /
services / lodging; else 1. Score = niche×10 + has-website. Filter by geography (`state`,
`cities[]`).

## The batch-1 plan

First ~100 postcards → owner-operated businesses in the **West Milford / North-NJ home
corridor** (verifiable in person, local word-of-mouth, highest conversion). NOT a
single-niche run (per-niche pool too thin). Arizona strip complexes = batch 2 (cold market +
strip density; needs a Prospector discovery sweep). Archetype = Powell Flooring. See
`docs/GOING_FORWARD.md` §1 for the exact `dz pcqueue` command.

**Before mailing:** enrich each target site from its real website via the
[source-of-truth cache](source-of-truth-cache.md) so a scanned card lands on an accurate,
better page.

See also: [Hot-Leads CRM (doozer-prospects)](doozer-prospects.md),
[claim codes](claim-codes.md), [postcards](postcards.md), [the flywheel](the-flywheel.md).

---

# Postcards

`systems/postcards.md` · type: System · tags: story, brand, content, admin

> The living-story system. Short or long dispatches that tell Doozer's WHO/WHAT/WHEN/WHERE/WHY/HOW/VISION, stamped to the moment they were written.

# Postcards

Postcards are how Doozer tells its own story, on purpose and over time. A Postcard
is one dispatch about why Doozer exists, what it does, how, and for whom. It can be a
tweet, a short note, an essay, or a quote. Admin lives at **Admin → Story → Postcards**
(`includes/admin-postcards.php`); the engine is `includes/postcards.php`.

## The shape

Seven lanes, the journalist's six questions plus a vision lane:
WHO / WHAT / WHEN / WHERE / WHY / HOW / VISION (`postcardDimensions()`).
Four formats: tweet, short, essay, quote (`postcardFormats()`).
Audiences: everyone, entrepreneurs, investors, operators, press, local customers,
for-the-record (`postcardAudiences()`).

## Time and perspective (the reusable idea)

Every Postcard is written "from where the story is today" and stamped with the platform
version and date (`as_of_version`, `as_of_date`). As the platform changes, you write new
Postcards; the old ones stay. The collection becomes the story's own changelog, readable
from any moment. Evergreen cards can carry live tokens (`{site_count}`, `{cloud_count}`,
`{version}`, `{year}`) so they always speak in today's numbers (`postcardFillTokens()`).

## AI drafting

`postcardAiDraft($dimension,$format,$audience,$voice,$brief)` drafts from live platform
signals (`postcardSignals()`) plus Doozer's ground-truth story facts, in a chosen voice.
Hard rules baked into the prompt: no em-dashes, no invented facts, speak from today,
plain language, no AI-slop. Returns title + dek + body for the operator to review before
saving. Distinct from [Marketing](../index.md) build-in-public posts: Postcards are the
deep, evergreen story; marketing posts are transient.

## Storage + seeding

`postcards` table self-heals on first use (FTP deploys do not run migrations). On first
run, `postcardSeedIfEmpty()` loads 31 authored starter cards across all seven lanes
(`postcardSeedSet()`) so the collection has a real voice from day one.

## Reuse

Postcards are source material: the doozer.work "Because" copy, an About page, a press
kit, the investor narrative, marketing seeds. Write once, retell often. They are the
human-readable companion to [Atlas](../decisions/okf-adoption.md) (machine-readable) and
the distilled [Doozer Story](../decisions/the-doozer-story.md).

---

# Promo Codes + the one-time claim offer

`systems/promo-and-offers.md` · type: System · tags: promo, coupons, stripe, billing, doozer-plus, claim, offer, monetization

> Reusable Stripe-backed discount codes (the promo generator) and the truly-one-time Doozer+ 50%-off-for-life offer shown on claim. Money-safety, single-fire, and how they map to Stripe coupons.

# Promo Codes + the one-time claim offer

Two things, one file (`includes/promo-codes.php`), one Stripe primitive (coupons). Shipped
v2.2.411-413. All Stripe calls go through `billingStripeRequest()`; creating a coupon is
config, not a charge — money only moves when someone completes checkout.

## The Promo Code Generator (build-once)

Admin: **Revenue → Promo Codes** (`admin-promo-codes.php`). CLI: `dz promo do=create|list|void|lifetime`.
`promoCodeCreate($opts)` creates a real Stripe **coupon** + **promotion_code** (typeable at
checkout) + a local `promo_codes` row. Four shapes, extensible:

| kind × duration | Stripe | meaning |
|---|---|---|
| percent · forever | `percent_off` + `duration=forever` | X% off every renewal, FOR LIFE |
| percent · once | `percent_off` + `duration=once` | X% off first period, regular after |
| amount · once | `amount_off` (cents) + `duration=once` | $X off first period, regular after |
| amount · forever | `amount_off` + `duration=forever` | $X off every renewal, FOR LIFE |

(`repeating` + N periods also supported.) One-time-use by default (`max_redemptions=1`).
`promoCodeVoid()` deactivates the Stripe promo code + local row. Codes apply to any
subscription SKU (default `premium_yearly`). The manual "someone begs" recovery code is just
`kind=percent value=50 duration=forever` → e.g. `PLUS50LIFE`.

## The one-time claim → Doozer+ offer

When someone CLAIMS their site is the perfect moment. On `?ajax=claim_code_redeem` success
(`index.php`), if the business has never been shown the offer, attach it; the redeem page
(`redeem-code.php` → `showClaimOffer`) shows **Doozer+ 50% off for LIFE, $49.50/yr forever**
with exactly two buttons:

- **Accept** → `?ajax=claim_offer_accept` → `billingCreateCheckoutSession('premium_yearly', …, $coupon)`
  with the lifetime coupon `promoLifetimeCoupon()` (`percent_off:50, duration:forever`,
  deterministic id `doozer-plus-50-forever-<mode>`) auto-applied. Marks the offer `accepted`.
- **Decline** → `?ajax=claim_offer_decline` → marks `declined`. Gone for good.

**Truly one-time:** `business_offers` (UNIQUE `(business_id, offer_key)`); `promoBusinessOfferShow()`
uses `INSERT IGNORE` and returns true only the FIRST time — so a 2nd scan, a resend, or a
revisit never re-shows it. No code, no take-backs. Doozer+ ONLY (never Keeper); all future
upgrades at regular price. The admin recovery code is the deliberate escape hatch for someone
whose card failed.

`billingCreateCheckoutSession($sku,$user,$s,$c,$extraMeta,$coupon='')` — a non-empty `$coupon`
auto-applies `discounts[0][coupon]` and drops `allow_promotion_codes` (Stripe rejects both).

Related: [billing](../../includes/billing.php), [claim codes](claim-codes.md),
[implicit verification]([[project_implicit_verification]]), [[feedback_right_offer_right_audience]].

---

# Prospector (discovery + demand-driven growth)

`systems/prospector.md` · type: System · tags: prospector, discovery, growth, places, demand

> How Doozer finds new businesses to build — the Grand Opening Finder, Cloud Radar, and the hybrid Google Places discovery engine.

# Prospector

Prospector is how the fleet grows on purpose instead of at random: it discovers
real businesses worth building and feeds them into
[site creation](../processes/site-creation.md) and [Clouds](clouds.md).

## Grand Opening Finder

Single-shot discovery → filter → review → one-click create. A sweep resolves a
center (ZIP or city/state via `prDiscoveryResolveCenter()` in
`includes/prospector-discovery-engine.php`), queries Google Places, and keeps only
**fresh openings** (OPERATIONAL + very few reviews + not already known). Results
land in `pr_discoveries` / `pr_discovery_sweeps` with per-call cost recorded.
Niche-optional: pick "Any niche" for a regional pass or one of 39+ types.

## Hybrid Places dispatch

OLD Nearby Search for legacy types + Places API (New) `searchNearby` for newer
types and the any-niche path, normalized to one response shape. Per-call cost is
tracked (see [API cost discipline](../decisions/no-dark-patterns-ethos.md) is not
it — cost discipline is an operator rule: check whether each pull costs, reuse
cache, pull only the unavoidable remainder). Cross-root ecosystem awareness reuses
an existing `businesses` row when a place is already known (skips paid detail +
research, saving ~$0.50/business), with a same-root duplicate guard.

## Scheduling + Radar

`pr_discovery_schedules` + a traffic-driven runner (rate-limited via
`platform_settings`) run sweeps automatically. **Cloud/Idea Radar** extends this
to demand: signals like Google Search Console impressions reveal what people are
already looking for, so Doozer can build the [Cloud](clouds.md) or vertical the
market is asking for. Discovery → build → auto-draft [marketing](marketing.md)
post closes the loop: a new site can announce itself.

Prospector is the intake valve of [the flywheel](the-flywheel.md).

---

# Regeneration + Foundation (keep 2,400 sites fresh and safe)

`systems/regeneration.md` · type: System · tags: regeneration, foundation, versioning, staleness, fleet, ops

> The unified regeneration engine, version stamping, and staleness detection that let the whole live fleet be improved without taking anything down.

# Regeneration + Foundation

Because a Doozer site is [generated from data](architecture.md), every site can be
regenerated by today's pipeline. With 2,400+ live sites, the challenge is doing
that **safely, at scale, without ever going dark.** The Foundation Sprint built the
machinery.

## Version stamping + staleness

Every generated artifact is version-stamped, so the system can detect which sites
were built by an older pipeline and are now stale. This is what makes a fleet-wide
upgrade knowable instead of guesswork: "which sites still need the new
[section framework](section-framework.md) / [family](../glossary/family.md) fix?"

## The unified regeneration engine

`includes/regeneration.php` — `regenerationRun($bizId, $mode)`. Modes:
`recolor`, `redesign`, `reroll`, `content`, `full`, `fix_readability`. Each is
**cost-aware** (content-only regen is ~$0.02 and keeps the design) and
**quality-gated with rollback** — a regeneration that fails the quality check is
reverted, so a bad AI run can never degrade a live site. This retires the old
"magical regen" in favor of à la carte control (checkboxes: photos / vision /
recolor / new-muse / content / full).

Key fact: **content regen never touches the muse.** The [muse](muse-system.md) is
resolved via a shared cascade (`museResolve`), not stored per-business, so
regenerating content leaves the design identity intact.

## The cockpit direction

The Foundation endgame is one **System Health / Upgrade cockpit**: see fleet
version distribution, detect stale, and run the [fleet upgrade runner](the-flywheel.md)
that calls the unified engine at scale (e.g. converting the elks.club sites in one
pass). Supported by the [Schema Audit](free-tier-render-gotchas.md) tool
(master-schema-vs-live drift) so a feature never silently no-ops in prod because a
column drifted.

This is the maintenance backbone that lets the [flywheel](the-flywheel.md) keep
turning: the fleet gets better as the pipeline does, on purpose, without downtime.

---

# Shortlinks + QR routing (doozer.link)

`systems/shortlinks.md` · type: System · tags: shortlinks, qr, doozer.link, routing, marketing, lead-flywheel

> Dynamic short URLs at doozer.link. The code encodes doozer.link/<slug>; the destination is editable, so a printed QR can be re-pointed without reprinting. The routing layer for the observed-world lead flywheel.

# Shortlinks + QR routing (doozer.link)

`doozer.link` is a dedicated shortlink host. A QR or shared link encodes
`doozer.link/<slug>`; the slug maps to an **editable destination**, so the same
printed code can be re-pointed later without reprinting. This is the routing
layer for the [observed-world lead flywheel](../decisions/the-observed-world.md):
claim postcards, direct mail, and campaign links all route through here so they
stay dynamic.

## How it routes

`index.php` recognizes host `doozer.link` (or `*.doozer.link`) at the top of the
routing block and calls `shortlinkHandleRequest()` in `includes/shortlinks.php`.
That parses the first path segment as the slug, resolves it (single indexed
lookup), increments `click_count` / `last_clicked_at` best-effort, and **302s**
to the destination (302 + `no-store` so destination changes take effect and
nothing caches). Unknown or inactive slugs bounce to `doozer.work` (no dead
ends); it serves its own robots.txt (disallow all).

The `shortlinks` table **self-creates on first use** (`shortlinkEnsureTable()`),
so doozer.link works the moment DNS points here, before the migration
(`migrations/sprint_shortlinks.sql`) is run. Columns: `slug` (unique),
`destination_url`, `label`, `business_id`, `campaign`, `is_active`,
`click_count`, `last_clicked_at`.

## Managing + minting

Admin → Infrastructure → **Shortlinks** (`includes/admin-shortlinks.php`):
create (auto or custom code), edit the destination inline, activate/deactivate
(the off switch that does NOT break a printed QR the way delete does), per-link
click counts, one-click copy, and a client-side QR generator with PNG download.

Programmatic: `shortlinkCreate($destination, ['slug','label','business_id','campaign'])`
returns `['ok','slug','url']`; other features (claim postcards, direct mail) mint
links this way. Dev preview: `?e=devapi&action=shortlink&create=<url>` /
`&resolve=<slug>`.

## Known permanent slugs

- **`doozer.link/thanksio`** → `https://thanks.io/?afmc=doozer` (Jon's thanks.io
  affiliate link). Printed as fine print on every Postcard Factory card ("Printed and
  mailed with thanks.io · doozer.link/thanksio", `postcard-factory.php`), so every
  "where did this come from?" is a possible affiliate signup — claim or no claim.

## Operator note

DNS for doozer.link points at this LAMP server (like the other alias domains);
Doozer handles it entirely in code by `HTTP_HOST`. There is no admin or business
site on doozer.link — every path is a slug lookup. Mint a custom slug with
`shortlinkCreate($url, ['slug' => '...'])` or `dz shortlink create= slug=`.

---

# Shortlinks and QR (doozer.link)

`systems/shortlinks-and-qr.md` · type: System · tags: shortlinks, qr, claim, doozer-plus

> One permanent, claim-aware doozer.link + QR per site — the printed asset that flips from the claim page to the real site on claim.

# Shortlinks and QR (doozer.link)

Every Doozer site gets **one permanent shortlink and QR** — a real, printable asset. It
lives at `doozer.link/<slug>` and is claim-aware, so the *same* printed code changes
where it points the moment the business is claimed. Engine: `includes/shortlinks.php`
(host `doozer.link` → `shortlinkHandleRequest` from index.php). Table: `shortlinks`
(self-heals).

## The permanent, claim-aware site link

- `kind='site'` marks the canonical per-business link (`campaign` = static per-campaign,
  the old default). `is_primary` marks THE current one (the Doozer+ vanity, once set).
- `shortlinkForSite($bizId)` get-or-creates the primary site link (idempotent).
- **Resolution is computed LIVE at redirect time** (`shortlinkSiteDestination`): if the
  business is claimed (`admin_email` set) and has an active domain → the real site; else
  → the redeem page (`?page=redeem&b=<id>` — business identified, the code is NEVER in the
  URL, see [implicit verification](../decisions/implicit-verification.md)). So the flip
  needs no destination edit and no chasing every claim path.
- Claim postcards use this same link ([claim codes](claim-codes.md) `make_shortlink`), so a
  mailed QR switches to the site on claim. Newly-claimed businesses get the link created at
  redeem time.

## The QR image

`?e=qr` (in api-endpoints.php) — a same-origin QR image, proxied + cached from
api.qrserver.com to `cache/qr/`. Params: `slug|data`, `size`, `fg`, `bg`, `format` (png|jpg).
Same-origin (with `Access-Control-Allow-Origin:*`) is required so the owner panel can export
a **transparent PNG** on a canvas (a cross-origin image would taint it) and any surface can
**copy the QR image** to the clipboard (`siteQrCopyScript()`, `[data-qr-copy]`, a fetch →
`ClipboardItem` → toast).

## Owner surface + on-site display

- `?page=manage` (`manage-business.php`, owner-gated) → the "Link & QR" panel: the
  doozer.link big with one-click copy, live QR preview, color picker, high-res downloads
  (PNG transparent / PNG white / JPG white), scan-prompt copy, and a switch to show the QR
  on the site.
- On-site display: `includes/site-qr.php` draws a muse-hardened footer QR card
  ("Scan to keep us handy"). Setting = `businesses.custom_settings.qr_display`
  (off|footer|header|both); **on by default for claimed** (`siteQrShouldShowFooter`).
  Header/hero placement is a future muse/design pass.

## Doozer+ vanity

`shortlinkSetVanity($bizId, $slug)` sets a friendly slug (e.g. `doozer.link/powell-flooring`)
as the new primary; **old slugs stay active and keep resolving** (old printed QR still works —
"use the new one going forward"). Doozer+ gated (`businessIsPremiumOwned`), owner-gated
(`?ajax=manage_vanity`). Postcards auto-print the primary, so they pick up the vanity with no
extra wiring.

See also: [claim codes](claim-codes.md), [the flywheel](the-flywheel.md),
[postcards](postcards.md).

---

# Source-of-truth cache (read the real website)

`systems/source-of-truth-cache.md` · type: System · tags: enrichment, accuracy, scraping, gigo

> A business's official website cached as markdown (Jina Reader) — the accuracy foundation for enrichment; enrich content, never overwrite location.

# Source-of-truth cache

86% of our businesses have an **official website** — the highest-signal reference material
for getting hours / services / menus / products RIGHT. Reading it closes the
generate-from-Places accuracy gap that produces wrong menus and products. This is
[GIGO](location-data-enrichment.md): better signal in, better site out.

## How it works

`includes/source-cache.php`. Fetches the business `website` as clean markdown via **Jina
Reader** (`https://r.jina.ai/<url>` — free, no key, renders JS; a hosted GET, nothing to
install, which fits the FTP/no-build model). An optional `jina` api_keys entry (Bearer)
raises limits but is not required. Cached in `business_sources` (business_id PK, url,
markdown MEDIUMTEXT ≤80KB, title, bytes, status, fetched_at) — **fetch once, refresh on
demand** (cost discipline). Tool: `dz source biz=<id> [refresh=1]`.

Note: [Firecrawl](firecrawl.md) never worked for us — Jina Reader replaces that need.

## The enrichment leg (built on top of this)

1. **Fact extraction** — AI over the cached markdown → structured facts (hours, services,
   menu + prices, products, team, service area, booking URL). Accuracy-first: null when the
   source is silent, NEVER invent.
2. **Validation filter** — cross-check the generated Doozer site vs. the facts; the source
   wins on conflicts; flag low-confidence for review.
3. **Never copy prose, extract facts** — accuracy + no duplicate-content SEO hit (the
   [SEO cliff](../decisions/seo-cliff-recovery.md) was scaled-content demotion).
4. Deep extractions (full menus, product catalogs) are a **Doozer+** feature.

Pairs with [postcard targeting](postcard-targeting.md): enrich the target sites from their
real websites BEFORE mailing, so a scanned card lands on an accurate, better page.

## GEO-ANCHOR RULE (load-bearing)

Enrich **content** (services / products / menu / hours / about / brand). **NEVER overwrite
the site's LOCATION/address** — each Doozer site is already geo-verified per its Places
lat/lng/city. Multi-location businesses list several addresses: the homepage **header** can
be an admin/mailing address while the storefront we want sits behind a "View all locations"
dropdown. Match the source location to OUR site's city; never take the header address. The
validation filter **flags** conflicts for review — it does NOT auto-clobber. Fix accuracy
without introducing errors (the taste filter).

Example: Powell Flooring's homepage shows Oakland NJ (admin); the wanted address is 1811
Union Valley Rd, West Milford (behind the dropdown). An anomaly — most businesses are a
clean single-address hit.

See also: [content generation](content-generation.md), [fact guard](fact-guard.md).

---

# Thanks.io Connector (direct mail, two-way)

`systems/thanksio.md` · type: System · tags: thanksio, direct-mail, postcards, radius, targeting, webhooks, claim-codes, prospector, intelligence

> How Doozer sends physical mail via Thanks.io and feeds the results back into intelligence — claim postcards, radius audience buying that reverse-engineers new sites, and delivery/scan webhooks.

# Thanks.io Connector

Thanks.io is a direct-mail API (postcards, letters, notecards) with radius
audience buying and QR/delivery webhooks. Doozer uses it **both ways** so every
send and every response compounds into the intelligence layer. Grounded in the
real API: base `https://api.thanks.io/api/v2`, auth `Authorization: Bearer <key>`.
Engine: `includes/thanksio.php`. Config-free, self-creating tables, portable.

## The three loops

1. **Mail the businesses we built (claim).** `thanksIoMailClaim($businessId)`
   mints a claim code + `doozer.link` QR ([claim-codes](claim-codes.md)) and
   sends a postcard to the business address carrying that QR and a warm message.
   Receiving it proves the address; scanning/entering claims the listing. Doozer+
   "pay first, snail mail, claim" verification uses the same path (`plus_verify`).

2. **Radius targeting → new site inventory (reverse-engineering).**
   `thanksIoBuyRadiusSearch(address, postal, count, {record_types, append_data})`
   buys 1–10,000 real addresses around a point ($0.05/record, +$0.20 with
   phone/email append), filtered by `record_types`: `all`, `onlybusinesses`,
   `newbusiness`, `newhomeowner`, `renters`, `absenteeowner`, `likelytomove`,
   `highnetworth`, `retired`, and more. `thanksIoIngestRadiusTargets(listId)`
   pulls the records into `thanksio_targets`; `thanksIoBuildFromTargets()`
   resolves each business record to a Google Place (`placesSearchBest`) and
   materializes a Doozer site (`resolvePlaceToEcosystem`). The mailing list
   becomes site inventory — which then earns its own claim postcard. Full circle.

3. **Webhooks back into intelligence.** `?e=thanksio_webhook` (gated by a shared
   secret in the URL — Thanks.io sends no signature) ingests
   `order.status_update` / `order_item.delivered` / `scans.scan_update` into
   `thanksio_events`, mirrors status onto the matching `thanksio_sends` row, and
   pings Comms: **delivered** = the address is reachable (the proof-of-address
   precondition), **scan** = the owner engaged before claiming (a demand signal
   for the Demand Ledger).

## Tables
- `thanksio_sends` — every send (preview or live): business, claim_code, order id,
  recipients, authorization_total, status, is_preview, qr url, address snapshot.
- `thanksio_events` — webhook events (unique on event_id), resolved to a business.
- `thanksio_audiences` — radius searches bought: center, record_types, count, cost.
- `thanksio_targets` — records pulled from a radius audience, staged for building
  (resolve_status new/resolving/built/existing/skipped/failed + business_id/place_id).

## Safety
Spend is real and irreversible, so: every send DEFAULTS TO PREVIEW (Thanks.io
returns an estimate, no charge) unless `live=1`; radius buys return a cost
estimate unless `confirm=1`; build-from-targets is bounded per call. Operator-
triggered only. Cost logged on every row ([[feedback_api_costs]]).

**Cost-units bug FIXED (v2.2.409):** Thanks.io returns `authorization_total` /
`leads_fee` ALREADY in cents (114 = $1.14 for a 4x6, 1 recipient); the send path
(`thanksIoSendPostcard`) was doing `round(value * 100)` → stored 100× too high
(11400 for a $1.14 card). Dropped the `*100`; `dz postcard do=fixcosts` re-derives
historical `thanksio_sends` rows from raw `meta.response.authorization_total`.
**Previews (proof images) live at `response.data.previews`** = [front, handwriting
back] S3 URLs; persisted to `postcard_designs.proof_urls` for the admin lightbox.

## Live postcard tracking (v2.2.407)
Delivery/scan/claim per send are read LIVE by `postcardDesignLiveStats()`
([[ai-postcards]]) from `thanksio_sends` + `thanksio_events` + `shortlinks` +
`claim_codes` (never cached JSON). The webhook now also recomputes the linked
`postcard_designs.tracking` on each delivered/scan event, so the admin "Sent &
performance" view stays fresh without a manual Refresh.

## Ops + setup
- `dz thanksio status | account | lists | webhook | radius | ingest | build |
  mailclaim | sends | events`.
- To go live the operator: (1) stores the API key (api_keys service `thanksio`,
  or `platform_settings thanksio.api_key`); (2) sets the return address
  (`thanksio.return_name/address/city/state/postal_code`); (3) sets a claim
  postcard front (`thanksio.claim_image_template_id` designed in Thanks.io, or
  `thanksio.claim_front_image_url`); (4) runs `dz thanksio do=webhook` to register
  the receiver. Then preview a `mailclaim`, and add `live=1` when happy.

## Back-of-card copy + response levers (v2.2.392)

The front is the Factory image; the BACK is where direct-mail response is won.
`thanksIoClaimMessageVariants($bizName,$code,$opts)` returns **warm / leads / curious**
variants, personalized with the town, no em-dashes. **The message never spells out the
claim code or the doozer.link URL** (v2.2.405): cursive handwriting makes codes/URLs hard
to read, and a code is already a friction point, so the back POINTS to them instead:
"Scan the QR code, or use the claim code printed on the front of this card." Code + URL
live only where they're legible: the front. Greeting frames the business as the OBJECT,
not a salutation: "Hi, we built Powell Flooring a real website..." (warm "you / yours"),
not "Hi Powell Flooring, we built you...".
`thanksIoDefaultClaimMessage` picks one (`opts.variant`). Operator levers (all
platform_settings, flowed through `thanksIoMailClaim`): `thanksio.handwriting_style_id`
(a handwritten back lifts response), `thanksio.size` (4x6 default; 6x9/6x11 stand out
but need a matching front aspect), `thanksio.message_variant`. Multi-campaign:
`postcardCampaignMessage` resolves the back copy per campaign (claim → these variants;
other campaigns → their `message_template` with tokens).

Related: [claim codes](claim-codes.md), [shortlinks + QR](shortlinks.md),
[AI Postcards](ai-postcards.md) (campaigns + Factory + Theme Studio),
[the observed world](../decisions/the-observed-world.md) (mail closes the CLAIM
loop; radius feeds OBSERVE), Prospector / place-resolver.

---

# The /now surface and the dense directory — the app NearByMeNow already almost was

`systems/the-now-surface.md` · type: System · tags: nbmn, pwa, mobile, ui, populate, measurement, app

> One button, the nearest real places, and Build-it-on-the-spot. Plus the measured teardown of the category page that was hiding its own businesses behind 855px of controls.

# The /now surface and the dense directory

**The operator's idea:** open an app wherever you are, press one button, see the nearest
few businesses, and either visit a live Doozer site or **build one standing right there**.
The person using it IS a Doozer — their presence is the contribution.

**The finding that shaped the work:** almost all of it already existed.
`checkin-button.php`, `follow-button.php`, `user-hotspots.php`, `business-card.php`, a
1,391-line Foursquare-style `nbmn-map-home.php`, and the endpoints `?e=businesses_near`
(geo-sorted JSON), `?e=nbmn_populate`, `?e=nbmn_build_place`. `/now` is assembly.

## `/now` (`includes/nbmn-now.php`)

Cookie-first location (`doozer_user_location`, one prompt per network, **never IP-geo**),
then `businesses_near` + `nbmn_populate` merged into one distance-sorted list. Built
places say **Open**; unbuilt say **Build it**. 86px rows. Route suppresses the site header
AND the 600px directory footer — an app shell is not a directory page.

Installable: `nearbymenow/manifest.webmanifest`, `nearbymenow/sw.js`, `assets/pwa/*`.

## MEASURED: what the category page was doing wrong

| | before | after |
|---|---|---|
| first business | 1,303px (1.32 screens) | **419px** |
| card height | 388px | **78px** |
| on the first screen | 2 | **5** |
| page height (24 results) | 11,995px | **4,611px** |
| horizontal overflow | 312 elements | none |

Fixes: a `compact` **surface** on the shared card (same markup, denser CSS — never a
second renderer, which would drift from the image fallback / toolbar / call modal); the
banner and type-chips folded behind one "Filter & sort" toggle; the header search form
capped (it was 456px inside a 375px viewport and caused every overflowing element).

## GOTCHAS, all of which looked fine to a DOM probe

1. **The front controller served `manifest.webmanifest` and `sw.js` as HTML.** A browser
   REFUSES to register a service worker with the wrong MIME type. Serve PWA files
   **before** the router, with explicit `Content-Type`.
2. **Cache-first on an app shell serves a stale page** and reads exactly like a failed
   deploy. Use **network-first with cache as the offline fallback** for anything still
   changing.
3. **A component honouring `prefers-color-scheme` on a host page with a hardcoded light
   body** gives dark rows on white. The shell must OWN `html, body`, tokens on `:root`.
4. **The host styles `header` at TAG level**, so any `<header>` inside a component
   inherits it. Same family as the business-site `!important` chrome rule.
5. **A script that snapshots elements at parse time misses markup below it.** The chrome
   toggle grabbed `.nbmn-chrome` 130 lines before the second block existed, and the page
   half-collapsed. Query at call time; apply after `DOMContentLoaded`.

**The discipline that caught 1, 3, 4 and 5: screenshots.** Every one of them returned
healthy values from `getBoundingClientRect` and friends. Only looking at the rendered
pixels showed a white strip, unreadable text, and a half-open filter panel.

Related: [[nearbymenow]], [[shared-business-card]], [[events-to-machines]],
[[the-observed-world]].

---

# The Architecture (one file, unlimited sites)

`systems/architecture.md` · type: System · tags: architecture, routing, multi-tenant, index, core

> How a single index.php serves every business site, root directory, Cloud, and NearByMeNow page in the network by detecting the host and routing the request.

# The Architecture

Doozer is a **single-file multi-tenant** application. There is one entry point,
`index.php`, and the whole network runs through it. What renders is decided by
the request's host, not by separate codebases or a CMS per site.

## The request, start to finish

1. `index.php` reads `$_SERVER['HTTP_HOST']` and classifies the request:
   - **Master** (`doozer.work`) — the platform landing page + admin console. Never a
     business page.
   - **API** (`?e=...` / `?ajax=...`) — routed to `includes/api-endpoints.php` (the
     `?e=` read+write API) or the inline `?ajax=` action switch.
   - **A root apex** (e.g. `elks.club`, `vsso.net`, `westmilford.biz`,
     `nearbymenow.com`) — the [root/Cloud directory](../processes/site-creation.md)
     listing many businesses.
   - **A business subdomain** (e.g. `oakridgeflorist.westmilford.biz`) — a single
     generated business site, rendered by `includes/business-site.php`.
   - **A Cloud host** or a **Vertical Directory** (e.g. `livemusictonight.com`) —
     see [Clouds](clouds.md) and [Live Music Tonight](livemusictonight.md).
2. The `domains` row for the host resolves the `businesses` row (the real data).
3. `business-site.php` composes the page: [muse](muse-system.md) CSS/type, the AI
   `ai_generated_content`, photos, sections, contact + lead form, the network footer.

## Why one file

Deployment is a file copy over FTP (no build step, no per-site config). A new
wildcard domain becomes a live business generator the moment DNS points at the
server. "Monolith with internal structure": the logic lives in `includes/*.php`
helpers, not scattered apps. The tradeoff is discipline. A regression in
`index.php` or `business-site.php` touches the whole fleet, so we
[verify by looking at live pixels](free-tier-render-gotchas.md) and
[deploy with backup + rollback](../processes/deployment.md).

## What a site actually IS

A Doozer site is **generated from data + a Muse**, not hand-built. The inputs
(Google Places data, scraped enrichment, reviews) persist in the DB; the output
is recomputed by today's pipeline. "Converting" or "upgrading" a site = re-running
the current [generation pipeline](content-generation.md) over the same inputs. The
site never goes dark, and it gets better as the pipeline improves. This is the
premise behind [the flywheel](the-flywheel.md) and the whole
[data-enrichment](location-data-enrichment.md) direction.

---

# The Chamber Chamber

`systems/chamber-chamber.md` · type: system

# The Chamber Chamber

**Find the people building your town.** A Chamber of Commerce already gathers a town's
businesses around a shared purpose. The Chamber Chamber (`thechamberchamber.com`) is a
directory of those Chambers with every member connected to its own Doozer page.

## What it is built on

Nothing new is collected. Directory Harvest (`directory_sources`, `directory_members`,
`dz harvest`) already reads a Chamber's public roster once, matches members to businesses
we hold or builds claim-ready sites, and records the read with its source URL and date.
The Chamber Chamber renders that record and **spends nothing** on a page view.

## The contract (from the September 2026 handoff)

- Every roster names its source URL and the date it was read.
- Membership means "this source listed them on this date". It is not a rating, an
  endorsement, a benefit or a partnership. The page says so.
- A member missing from a later read is "not seen in the latest roster", never removed and
  never "expelled".
- Member emails are used only to reach a business about its own page. **Never shown.**
- No dead forms. "Add your Chamber" and "Claim or correct" are mailto links to
  `agencies@doozer.work`; a Chamber joins with one email.
- Categories and roster order are the Chamber's own free text, grouped as given.

## Routes

| path | what |
|---|---|
| `/` | every harvested source, members-listed and on-Doozer counts, client-side filter |
| `/<chamber-slug>/` | source + read date, four facts, town board link, members by category |
| `/robots.txt`, `/sitemap.xml` | own branches in `index.php`, next to the town host |

Slug = `ccSlug(name)`. The town board link is `townSlugify(city, state)` from the
source's `region` ("West Milford, NJ").

## Members on the page

- **Linked** (`business_id` set, active domain): the shared `renderBusinessCard` compact
  surface, civic navy `root_color`. Never a second card renderer.
- **Unlinked**: name, address, "Their website", "Call", and when a `place_id` is known a
  "Build their page" button that posts to the existing `?e=nbmn_build_place` through the
  shared build modal. A visitor building a member's page is the honorary-Doozer loop from
  Doozer Town, applied to a Chamber.

## doozer.world

`includes/doozer-world.php`: the why in two screens. Three-line thesis (Doozer knows the
world; Logfile knows your trail through it; Wuz lets you go back), why it begins with
websites, thirteen doors each phrased as one human question with an honest status
(live / new / soon / concept; only live doors link), and three paths: I run a business,
I run a Chamber, I live somewhere. No forms, counters or testimonials.

## Not built yet, on purpose

- Logfile.me (Jon is moving the domain; the memory products wait on the trust decisions).
- A first-class `observation` row (observed_at, valid_from, valid_to, source). Today the
  read date lives on `directory_sources.last_harvested_at`, which is enough for one page
  but not for history.
- Re-harvest scheduling. A second read of a roster is what makes "not seen in the latest
  roster" real.

---

# The Doozer Brand System (ecosystem identity kit)

`systems/brand-system.md` · type: System · tags: brand, design, identity, palette, typography, ecosystem, ui, craft

> The canonical design identity for the whole Doozer ecosystem. Every human-authored surface (doozer.work, claim/auth/admin, roots, LiveMusicTonight, GoHe.re, Wuz.ai, AwesomeAddresses) shares one skeleton (neutrals, type, chrome, voice) and varies only its accent hue, so the network feels like one building with differently-lit rooms. Use this to build production-ready UI fast and on-brand.

# The Doozer Brand System

## Essence

Doozer **builds**. The personality is an industrious, honest builder: useful,
grounded, warm, confident, a little utilitarian. Never slick-for-its-own-sake,
never hype, never a [dark pattern](../decisions/no-dark-patterns-ethos.md). The
sign-off is the tagline **"We Build Because."** The feeling: *someone quietly
built you something good, and it works.* Optimistic green, warm paper, a spark
of gold.

## The mark — THE LATTICE (Aug 2026)

`doozerMarkSvg()` in `includes/doozer-brand.php`. **It replaced a 3D construction
helmet on a gear.**

The helmet named the inspiration literally, and the inspiration was never the point.
In Fraggle Rock the Doozers are not memorable for what they wore — they are memorable
for what they **built**: crystalline lattice, assembled tirelessly and joyfully, eaten,
and built again. The legacy worth keeping is the structure, not the worker.

The mark is a **D built from a grid**, most cells set, one gold, one still open. It
reads three ways at once:

| reading | what it is |
|---|---|
| physical | a street grid, a block, a town from above |
| digital | a pixel grid, a data structure, a disk defragmenting |
| the product | an open cell is a place nobody has opened yet |

The third reading is the software. It is the same idea as the blueprint plate the
directory draws for an unopened listing, so the brand and the product finally say one
thing. 1994's pixel grid and what we build with now are the same lattice at different
resolutions.

**The incompleteness is the point.** The cells are ordered as if built up from a
foundation: base and body set, the gold cell going in at the top edge, one outline
beside it waiting. The mark is unfinished because the network is.

Rules: drawn from `currentColor` plus one accent, so it inherits ink on any surface
and needs no light/dark variants. Below 20px every cell fills solid — the story is for
sizes where it can be read. **1.3KB of vector** in place of a 578KB PNG.
Static files: `assets/images/doozer-mark.svg` (on green, for favicons) and
`doozer-mark-mono.svg`.

Two earlier cuts were built, screenshotted and rejected: a 5x5 whose two special cells
read as stray teeth, and a 7x7 three-step fade that destroyed the silhouette below
24px. Neither survives in the code.

## The one rule (how the ecosystem coordinates)

Every property shares the **same skeleton** and changes only its **accent hue**:

- Shared across ALL properties: the neutral palette (paper / ink / cream /
  muted / line), the type system, the logo lockup + "We Build Because."
  sign-off, the [network footer](../systems/the-network.md), the sponsor bar,
  the voice, and the component shapes (radius, shadow, buttons, inputs, focus).
- Owned per property: **one accent family.**

Moving from doozer.work to LiveMusicTonight to GoHe.re should feel like walking
between rooms of one building: same architecture, same materials, a different
color of light. This is the coordination the operator wants: distinct, not
uniform; unmistakably one network.

## Color

### Core neutrals (every surface)
```
--paper:#faf8f2   warm page ground        --line:#e4e0d3   hairline borders
--cream:#f2efe4   secondary ground        --card:#ffffff   raised surfaces
--ink:#111b14     near-black green ink     --ink-soft:#33463a  body-dim
--muted:#5a6b60   captions / meta
```
Dark grounds (hero bands, dark sections): `#0a0f0b #0d1a12 #111b14 #1a2e20`.

### Brand green (the anchor accent)
```
--green:#12833c        primary brand green
--green-dark:#0c5c2a   buttons + small text on light (AA)
--green-bright:#29b356 hover / lively accent
```
Retuned Aug 2026, deeper and more luminous than the old `#1f9d3f`. The colours were
never the problem; the PAIRING was. Safety-green beside hi-vis amber **in equal
measure** is a work-site palette, and it was carrying more of the construction signal
than the helmet did.

### Gold spark
```
--gold:#ffc107      the piece being set — and nothing else
--gold-deep:#c98a00 gold as text on light
```
Gold is no longer a co-equal brand colour. It appears **once**, small, as the active
cell in the mark and as a genuine highlight. Green carries the identity.

### Contrast rules (non-negotiable, AA floor)
- Body text = `--ink` on `--paper`.
- Solid buttons = `--green-dark` bg + white text (AA). Do NOT put white text on
  `--green` (#1f9d3f is AA-large only) or on `--gold`.
- Gold is a **background / eyebrow** color with ink text, never white-on-gold.
- Always ship visible `:focus-visible` (gold outline works on every ground).

### Ready-to-paste tokens
```css
:root{
  --paper:#faf8f2; --cream:#f2efe4; --ink:#111b14; --ink-soft:#33463a;
  --muted:#5a6b60; --line:#e4e0d3; --card:#fff;
  --green:#12833c; --green-dark:#0c5c2a; --green-bright:#29b356;
  --gold:#ffc107; --gold-deep:#c98a00;
  --accent:var(--green); --accent-dark:var(--green-dark); /* override per property */
}
```
A page only needs to redefine `--accent` / `--accent-dark` to reskin for its
property. (Centralizing these as a `brandCssVars()` PHP emitter is the natural
next step so no surface hand-copies them.)

## Per-property accent map

| Property | Accent | Ground | Feeling |
|---|---|---|---|
| Core (doozer.work, claim, auth, admin) | builder green `#12833c` / `#0c5c2a` | paper | the workshop |
| NearByMeNow | star gold `#f5a623` (+ the [niche-color rainbow](../../reference_niche_color_system.md) for content) | paper | the town square |
| LiveMusicTonight | electric violet `#8b5cf6` + magenta `#ec4899` highlight | dark-first (`#0d1a12`) | after dark |
| GoHe.re (gatherings) | warm tangerine `#f97316` | paper | come together |
| Wuz.ai (presence) | electric cyan `#06b6d4` | paper / light | you are here, now |
| AwesomeAddresses (homes) | slate `#334155` + gold | paper | home, premium |
| Doozer.Cloud / personal | adaptive ([Vibe System](../systems/vibe-system.md): Mood x Energy) | user-chosen | your world |

The accents are hue-distinct but all sit on the SAME neutrals and type, so they
read as siblings. Directories (NBMN) also use the network-wide category-color
system for content chips; the property accent governs its chrome.

## Typography

**System sans** everywhere: `ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`. This is a
deliberate choice, not a default: it is fast, free (no webfont cost or FOUT),
universal, and its plainspoken honesty is on-brand for a builder. The character
comes from **weight, scale, tracking, and the green** — not a novelty face.

- Display / H1: same family, weight **800**, tracking **-0.02 to -0.025em**,
  size `clamp(2rem, 7vw, 3rem)`, line-height ~1.05.
- Headings: 700-800, tight tracking.
- Body: **>= 16px**, line-height ~1.55, `--ink-soft` for long prose.
- Eyebrow: 12.5px, `letter-spacing:.16em`, uppercase, weight 800, `--gold-deep`.
- Mono (`ui-monospace, SFMono-Regular, Menlo, monospace`): domains, codes,
  data, addresses.

Exception: **Doozer+ bespoke sites and the 100 muse families may use expressive
display faces** — that is the paid tier's and the product's differentiation. The
*network chrome* stays system-font.

## Space, shape, elevation

- Radius scale: **8 / 11 / 14px** (chips / controls / cards).
- Reading column: **620-680px**; directories/dashboards go wider.
- Cards: `--card` on `--paper`, `1px solid --line`, soft shadow
  `0 18px 40px -24px rgba(17,27,20,.5)`. Primary cards get a **5px left border**
  in `--accent`.
- Generous padding (20-24px in cards); let type breathe.

## Motion

Subtle and orchestrated, never scattered. A single **fade-up on load**
(`translateY(10px)->0`, ~0.6s, staggered 60-80ms) is the house move. Always wrap
in `@media (prefers-reduced-motion: reduce)` and disable transforms.

## Components (house patterns)

- **Button primary:** `--accent-dark` bg, white, radius 11px, weight 800,
  `:active` nudges 1px, `:focus-visible` gold outline.
- **Button ghost:** `--paper` bg, `--ink` text, `1.5px --line` border.
- **Input:** `--paper` bg, `1.5px --line`, focus = accent border + soft accent
  ring. Body-size text.
- **Eyebrow:** gold-deep, uppercase, tracked (see type).
- **Browser-frame preview:** a "site card" with a bar (three dots + mono
  domain), a cached page **screenshot** (or a branded placeholder), and a
  tap-to-open row. See `includes/claim-listing.php` `claimPreview()`.
- **Network footer / sponsor bar:** shared components
  ([network-footer](../systems/the-network.md), sponsor-bar); every property and
  every muse site carries them so even a wild site feels networked.

## Voice

Plain, active, warm, confident. Name things by what the user controls. Errors
are honest and helpful, not apologetic or vague. **No em-dashes** in
user-visible copy (periods / commas / `·`); apostrophes fine. No slop, no hype,
no invented taglines (the tagline is fixed: "We Build Because."). See the
[mission](../decisions/the-mission.md) and `includes/design-standards.php`.

## Accessibility floor (ship every time)

AA contrast (rules above), visible keyboard focus, `prefers-reduced-motion`
respected, mobile-first, body >= 16px, real `alt`/labels.

## Chrome vs. muse (where the brand applies)

- **Network chrome** — human-authored surfaces (landing, claim, auth, admin,
  per-root themes like [elks](../../project_elks_root_theme.md)) and the wrappers
  around every site (footer, sponsor bar): **governed by this brand system.**
- **Business sites** — the 100 [muse families](../systems/muse-system.md) are
  deliberately diverse; that variety IS the product, governed by
  `design-standards.php` + `muse-apply.php`. They still wear the shared chrome.

## Where it lives in code

Tokens currently declared inline on `includes/landing-page.php` and
`includes/claim-listing.php`; category content color in
`includes/niche-colors.php`; site-generation rules in
`includes/design-standards.php`. **Next step to move faster:** a
`brandCssVars($property='core')` emitter (a small `includes/brand.php`) so any
new page drops in the tokens + its accent in one line instead of re-declaring
them. This node is the source of truth until then.

---

# The Doozer Flywheel

`systems/the-flywheel.md` · type: System · tags: strategy, flywheel, network

> How Doozer, NearByMeNow, LeadBuilder, and Prospector reinforce each other into a self-feeding local-business engine.

# The Doozer Flywheel

Four surfaces over one dataset, each feeding the next:

- **Doozer** builds the business sites (one codebase, many domains, the
  [Muse System](muse-system.md) styling each).
- **NearByMeNow (NBMN)** is the directory/traffic layer — geo + [niche](../glossary/niche.md)
  discovery that routes visitors to the sites and back (the
  [shared card](shared-business-card.md) renders the listings).
- **LeadBuilder** captures and routes demand: [call intent](shared-business-card.md),
  lead requests, and `lb_events` telemetry become qualified leads for owners.
- **Prospector** mines real demand (GSC search data, inventory gaps) to decide what to
  build or grow next — the Idea/Cloud Radar.

The strategic fact underneath: Doozer builds, organizes, hosts, attracts, markets, and
TRACKS the local physical business economy in real time. Owners verify by
[implicit verification](../decisions/implicit-verification.md) (no claim system). The
graph of who-is-where-in-what is exactly what the OKF
[network nodes](../index.md) expose.

---

# The Doozer Score — the Stick, the Tower, and the leverage between them

`systems/the-doozer-score.md` · type: System · tags: metrics, cost, value, digest, telegram, strategy

> One tracked number for whether Doozer is getting cheaper and more valuable at the same time. Stick = cost of 100 sites, Tower = replacement value of 100 sites, Score = Tower / Stick.

# The Doozer Score

Fraggle Rock Doozers build towers out of construction sticks. So do we.

- **STICK** — what 100 Doozer sites cost to build. Measured from our own ledgers
  (`ai_generation_history` + `places_api_calls`) over a trailing 30 days, never estimated.
- **TOWER** — what those same 100 sites would cost the owners if a human web developer
  built them. Replacement cost at `doozer_score_site_value` (default **$750/site**, a
  conservative floor for a small-business one-pager).
- **DOOZER SCORE = TOWER / STICK** — leverage. How many dollars of delivered work every
  dollar of spend produces. It rises when cost falls **or** value rises, which is exactly
  the pair of things worth watching together.

First reading (2026-08-22): **Stick $24.66 · Tower $75,000 · Score 3,041x.**
Network at replacement value: **$2.70M** across 3,602 sites.

## Where it appears

`includes/doozer-score.php`, `dz doozerscore [days=30] [record=1]`.
A row lands in **`doozer_score_daily`** every time the pulse or digest runs, so the trend
is *measured* rather than recomputed from memory. Rendered in:

- the **Telegram pulse** (`doozerScoreLine()`), deliberately right next to the spend line
  so cost is never read without value beside it;
- the **operator edition of the Doozer Digest** (`doozerScoreHtml()`).

**It is never shown to subscribers.** The digest email goes to real people who followed a
local business; they get their neighbourhood news, not our unit economics. The score is
attached only when `$userId === null` (the un-personalized network edition).

## The honesty rules — this number is flattering and could rot into vanity

1. **TOWER IS REPLACEMENT COST, NOT REVENUE.** It is what the work would cost to buy
   elsewhere. Nobody paid it. `realized_usd` reports actual revenue separately and the
   two are **never blended**. Today realized is $0, and the digest says so out loud.
2. **The rate is an assumption and is always stated** wherever the score is shown.
   Change `doozer_score_site_value`, change the number.
3. **STICK is marginal production cost only** — AI plus Google APIs. It excludes hosting
   and operator time, and says so.

## Why it exists

Doozer got a $1,300 surprise bill because cost was invisible. The reflex fix was a spend
cap, which controls cost but tells you nothing about whether the spending is *worth it*.
The Score is the other half: it makes "we got cheaper" and "we got more valuable" a single
line the operator reads every morning.

Moving the writer to `gpt-5.4-mini` ([[writer-model-and-accuracy]]) took the AI share of a
Stick from $3.40 to $0.75.

Related: [[the-flywheel]], [[writer-model-and-accuracy]], [[events-to-machines]].

---

# The Elks Root Theme (bespoke root identity, a case study)

`systems/elks-root-theme.md` · type: System · tags: elks, root, theme, identity, case-study, design

> How an entire root domain gets a hand-authored identity layer over the Muse engine — the elks.club star-spangled theme — as the pattern for premium root branding.

# The Elks Root Theme

`elks.club` demonstrates the pattern for giving a whole [root](the-network.md) a
**bespoke identity in code**, layered over the per-site [Muse](muse-system.md)
rather than replacing it. It is not a DB muse; it lives in
`includes/elks-root.php`, gated by `elksIsRoot()`, and is applied via
`html body`-scoped `!important` CSS so it wins over arbitrary muse rules without
editing them.

## What it does

A dignified B.P.O.E. (Benevolent and Protective Order of Elks) treatment for
July 4th and beyond: deep navy + deep red with professional gradient fades,
Playfair display type, a Virtues ribbon (Charity / Justice / Brotherly Love /
Fidelity), and heritage bands. Signature elements:

- **RWB LED bar** — an animated red-white-blue "patriotic laser" divider (tricolor
  sweep + hairline edges + glow), replacing a flat border. Repeats as a footer
  masthead rule.
- **Footer masthead** — three page-centered cells (Lodge ID left, legal center
  under the white LED segment, `elks-white-logo.webp` right).
- **Gold edges** — a tunable gold pinstripe only at blue↔non-blue section seams
  (never on the LED bar), plus a hero-top pinstripe.
- **The "11" wax-seal jewel** — a custom SVG divider (gold radial disc, dashed
  wax-seal ring, embossed bronze "11") marking the Eleven O'Clock remembrance, at
  specific dark-band boundaries.
- **Marketing surfaces** — a "Who are the Elks?" centered modal + Join CTA to
  `https://join.elks.org`, sourced from official Elks material.

## Why it is the pattern

A root can carry a shared identity that is *cohesion, not monoculture*: every lodge
site keeps its own content and photos, but shares the star-spangled chrome. This
is the [premium / Doozer+](../decisions/two-tier-model.md) direction proven on a
real fleet. The build taught durable lessons folded into
[render gotchas](free-tier-render-gotchas.md) and the site-quality backlog:
native `<dialog>` centering needs `position:fixed`; scroll-reveal must force
`transform:none` on forced-visible bands; PHP heredoc octal escapes (`\\2605`);
gold-edge specificity must exceed muse button rules. Content quality still follows
[GIGO](location-data-enrichment.md) — the theme makes a lodge look dignified; real
[enrichment](location-data-enrichment.md) makes it sound like itself.

---

# The Growth Engine — observe → serve → convert → learn

`systems/growth-engine.md` · type: System · tags: growth, acquisition, monetization, flywheel, doozer-plus, postcards, hot-leads, intelligence, strategy, overview

> The map of Doozer's acquisition + monetization machine. How observing a business, painting it a real site, sending it leads, tracking every touch, and making the RIGHT offer at the RIGHT moment turns into revenue — and how the intelligence layer decides who to reach, how, and when. The purpose, the smarts, and the wiring, with links to every component.

# The Growth Engine — observe → serve → convert → learn

This is the map. Doozer's whole reason to make money runs on one flywheel; the detailed
mechanics live in the component nodes linked throughout. Read this first, then dive where
you need to.

## The purpose (the WHY)

Realizes the [[the-observed-world]] pivot: **every business exists once observed** — we paint
it a real, live site on demand, and it already has leads waiting. From there:

> They know us → we send them value (real leads) → we make the right offer at the right moment
> → free becomes Doozer+ ($99/yr) becomes Cloud Keeper ($499/yr) becomes agency/franchise.

When a business is aware of Doozer AND receiving leads from us, **$99/yr feels like a stepping
stone**. The engine's job is to (1) create that awareness + value, (2) recognize the moment,
(3) make the honest offer, (4) measure what worked, (5) do more of it. No dark patterns
([[project_doozer_ethos]]); anonymous browsing + the free claim always work.

## The stages + the wiring

**1. OBSERVE + PAINT.** A business is discovered ([[prospector]]) or rendered on demand; the
Factory paints a real site (screenshot + real content). [[the-observed-world]], [[network-growth]].

**2. SERVE VALUE (leads).** Directory + contact forms capture consumer leads; they route to the
business's one inbox (`businessLeadRecipientEmail`, admin_email→scraped fallback) and pipe to
LeadBuilder (`nbmnLeadPipeToLeadBuilder`). The lead-delivery email itself carries the right
Doozer+/claim pitch (below). This is the "we give value" signal — the strongest driver of
conversion readiness.

**3. REACH (the touch).** Physical mail is the workhorse when we have no email:
- **Postcard Factory** ([[ai-postcards]]) — deterministic HTML→PNG card: real screenshot,
  real claim code, real doozer.link short URL + scannable QR, no per-card AI cost. **AI Theme
  Studio** designs vision-gated backgrounds (two-score design+accuracy gate). **Campaigns** give
  each card a purpose + audience. **Size is a variable** (4x6 vs 6x9, same 2:3 art) for A/B.
- **Thanks.io** ([[thanksio]]) mails it + webhooks delivery/scan back. Send is preview-by-default;
  live costs money (behind a confirm). Cost is per-send on `thanksio_sends` (returns cents).
- **Claim codes** ([[claim-codes]]) — the code on the card is a strong secret; scanning the QR
  or short link lands on `?page=redeem&b=<id>` (non-secret); the code is TYPED off the card
  (brute-force safe). Redeeming = claiming ([[project_implicit_verification]], no claims table —
  ownership is an email match; `admin_email` authoritative once set).

**4. CONVERT (the offer).** The right offer to the right audience at every touch, from ONE
resolver — never mis-targeted:
- **`doozerPlusOfferFor($businessId)`** (`premium.php`) → stage `claim` / `plus` / `premium` /
  `agency` with canonical copy (`doozerPlusBenefits`, mirrors `?page=plus`). Wired into the
  lead email, the owner manage page, and **the owner viewing their own free site** (a hardened,
  dismissible nudge). [[feedback_right_offer_right_audience]].
- **The one-time claim offer** ([[promo-and-offers]]): the moment someone claims, they get a
  truly one-time deal — **Doozer+ 50% off for LIFE ($49.50/yr forever)** — accept (Stripe checkout
  with the lifetime coupon) or decline (gone for good). Single-fire per business (`business_offers`);
  never from a code, a 2nd scan, or a resend. Doozer+ only, never Keeper.
- **Promo Code Generator** ([[promo-and-offers]]): reusable Stripe-coupon-backed codes
  (%/$ × once/forever) — including the manual "someone begs" recovery code. Billing is
  product-agnostic Stripe (`billing.php`, one connection sells everything).

**5. MEASURE + LEARN.** Every touch is tracked and fed back:
- **Postcard performance** ([[ai-postcards]]): per-send delivered/scanned/visited/claimed/
  converted, live from `thanksio_sends`/`thanksio_events`/`claim_codes`/`shortlinks`
  (`postcardDesignLiveStats`). **Format-performance** panel compares 4x6 vs 6x9 by
  niche/state/timeframe with thin-sample flags — the "which format/niche/market converts" science.

## The smarts (the intelligence layer)

**Hot Leads — the Internal Affairs Division of LeadBuilder** ([[doozer-prospects]],
`doozer-prospects.php`). Every business is one of OUR prospects; it becomes a *lead* once we've
touched it (mailed, delivered a lead, or it claimed):
- **Awareness score** (`businessDoozerAwareness`, 0-100): claim (+40), scanned (+18), delivered
  (+6), postcards sent (+3 ea), **leads we delivered (+12 ea — the "we give value" driver)**,
  live site (+4). Status hot/warm/cold/customer.
- **Next best action + channel** (`doozerProspectAction`): getting leads + email → **email**;
  getting leads + **NO email → mail a postcard STAT** (these float to the top — leads with no
  inbox = money on the table); mailed already + no email → **call**; claimed+free → the Doozer+
  pitch. **SMS is OUT (regulatory).** FB Messenger via official page = future.
- **Projected claim %** (`doozerProjectedClaim`): a transparent heuristic today (no real
  outcomes yet); SHARPENS as claim/convert rates by niche+market accrue from the
  Format-performance data. The learning loop: mechanics claim 20% more than bodegas → mail more
  mechanics.
- The admin **shouts** it: "Reach these N now — X mail STAT, Y email." Sorted urgency-first.
- **Piped into LeadBuilder** (`doozerProspectsPipeToLeadBuilder`): each prospect becomes an
  `lb_leads` row (source `doozer_internal`) so internal prospects live inside LeadBuilder proper.
- Distinct from **Machi** ([[machi]]) which scores OUTBOUND NEED ("who to mail next"); Hot Leads
  scores CONVERSION READINESS + the exact next move. Complementary lenses on one funnel.

## Guardrails (non-negotiable)
- No dark patterns; the free claim + anonymous browsing always work. The one-time offer is
  honest and single-fire, no take-backs.
- **No SMS** (regulatory). Channels: postal mail, email, phone; FB Messenger later.
- Right offer to the right audience — always via `doozerPlusOfferFor()`, never hardcoded.
- Minimize paid-API spend ([[feedback_api_costs]]); intelligence reads our own DB (free).
- Money moves only on an explicit action (live mail confirm; owner-initiated checkout).

## Shipped vs next
SHIPPED (v2.2.407-419): postcard tracking + resend + view, size/format science, the
audience-aware offer everywhere, the one-time claim offer + promo generator, Hot Leads
(awareness + action + channel + projection) + the LeadBuilder pipe.
NEXT: campaign/niche/market response comparison auto-weighting targeting; FB Messenger channel;
the **BYO-key thanks.io reseller Marketing Engine** (per-business keys via a
`getApiKeyForBusiness()` seam, owner send-to-their-leads on `manage-business.php`, 50% affiliate
revenue ledger, agency tiers) — reusing the Factory + the `/frontend-design` two-score vision gate.

---

# The Muse System

`systems/muse-system.md` · type: System · tags: design, muse, generation

> Doozer's AI-driven design engine — palette, typography, layout, and section variants that make each business site its own.

# The Muse System

A [Muse](../glossary/muse.md) is a design recipe (palette, typography, layout axes,
decorative flourishes, section variants) resolved per business and applied at render.
It is how one codebase produces visually distinct sites at scale.

## Pieces

- `includes/muse-core.php` — resolve the active Muse for a business (precedence:
  URL override, then `businesses.custom_settings.muse_id`, then
  `domains.custom_settings.muse_id`, then `root_domains.muse_id`). Muses live in the
  `muses` table; the design recipe is `reading_json`.
- `includes/muse-css-generators.php` + `includes/muse-apply.php` — turn the Muse into
  the per-site CSS (type scale, grid system, prose width, section tones).
- `includes/muse-contrast.php` — readability guards (AA contrast; heading-color swap
  when the brand primary is too pale; section background rhythm).
- `includes/muse-direction-families.php` — the 100-recipe [Family](../glossary/family.md)
  library the auto-generator draws from, niche-filtered.
- `includes/sections/<slug>/<variant>.php` — the [section framework](section-framework.md)
  partials a Muse picks variants from.

## CSS composition and safety layers

`museEmitHeadStyles()` (`includes/muse-apply.php`) is where a Muse becomes the page's
CSS. It composes in a deliberate ORDER, last-wins:

1. Archetype CSS (the family's base look: type scale, grid, section tones).
2. `custom_css_snippet` — the interpreter's AI-authored per-Muse flourishes, run
   through `museSanitizeCustomCss()` first (no `@import`, no external `url()`, no
   `</style` injection).
3. Contrast safety (`includes/muse-contrast.php`) — AA-safe prose colors against the
   effective backgrounds.
4. Button-visibility safety — emitted dead-last so nothing above can undo it.

Because these are RENDER-TIME guards, fixing one heals all ~2,400 live sites with NO
regeneration. That is the preferred way to fix a fleet-wide design fault.

### Two load-bearing safety guards (both born from real bugs)

- **A snippet must never redefine `--muse-*` design tokens.** The AI snippet once
  emitted `body{--muse-font:sans-serif}`, which clobbered the correct
  `:root{--muse-font:"Inter",…}` for the whole page, so every site rendered system-sans
  regardless of its family's type pairing. Fix: `museSanitizeCustomCss()` strips
  `--muse-font*` re-declarations (and tidies the emptied rules). Harden the interpreter
  prompt too if snippets keep doing this. (v2.2.207)
- **`.btn-secondary` is an outline button, so it always gets an outline.** Archetype or
  snippet CSS with `!important` could strip its border, leaving a white fill on a white
  header — the "invisible Directions button." The final safety rule is
  `html body .btn.btn-secondary { border: 2px solid currentColor !important; }`.
  `currentColor` is the button's own text color, which by definition contrasts its
  background, so the outline reads on light AND dark archetypes without forcing any palette.
  The selector specificity (0,2,2) must EXCEED the muse button-chrome rules (flat chrome
  emits `body[data-muse-btn="flat"] .btn { border:0 !important }` at 0,2,1) or it loses even
  with !important: the v2.2.209 rule shipped at (0,1,0) and was overridden on flat muses
  until the bump in v2.2.213. Design-correct (secondary = outlined), not a hack. Lesson
  worth keeping:
  verify design by LOOKING (screenshot + inspect every CTA's contrast on a calm AND a
  bold Muse), not by probing only the one token you changed.

## Decorative flourishes

Section dividers carry small SVG flourishes (`includes/muse-flourishes.php`,
`museEmitFlourish($slot, $muse)`, called at the top of about/services/photos/reviews/faq/
contact in `business-site.php`). The SVG library (arched frame, sunburst, scalloped,
brutalist bar, risograph, hand-drawn, diamond, hairline rule) pulls its color from
`--muse-accent`, so one SVG adapts to every palette.

Selection derives a compatible POOL from signals the Muse ALWAYS carries, most specific
first: an explicit `decorative_hints`/`homage_signals` keyword match wins, else the
`template_archetype` mapped to a curated taste-safe pool, else `typography.family`, else a
restrained default. Pools never mix clashing vocabularies (no brutalist bar on a florist;
civic and editorial archetypes stay restrained). The pool is rotated by a stable per-muse
seed (the muse id), so two muses of the same archetype can lead with different signatures
while a SHARED muse renders identically across its whole root or Cloud (cohesion is the
goal). Per-page rhythm: the signature slots cycle up to two harmonious marks; quiet slots
(photos/reviews/faq) get a hairline, never the same mark stamped six times.

This was a real sameness fix (v2.2.210): before it, any Muse whose AI hints did not match
the keyword map fell to the bland hairline on every section, and even matches repeated one
mark down the whole page. The lesson echoes the button bug: verify by LOOKING at the
rendered DOM, not by trusting that a signal was wired.

## Hero composition

v2 muses get a token-driven hero composition system (`museCssHeroComposition`, keyed by the
`hero_composition` token: full-bleed-tint, sidebar-quote, arched-portal, marquee-strip,
etc.). v1 muses do not, so every v1 hero rendered the same centered headline over a
full-bleed photo — a major sameness driver, since the visible fleet is largely v1.

`museHeroCompositionV1($muse)` (`includes/muse-apply.php`) gives v1 heroes a per-archetype
LAYOUT: 'center' (default) or 'editorial' (bottom-left, ranged-left, narrower measure) for
the clearly-editorial archetypes. It emits `data-hero-comp` on the `.hero` section, and the
scoped CSS in `business-site.php` changes only alignment and content measure. v2 muses
always return 'center' here so their own system stays authoritative.

LOCK: the hero overlay (`.hero-background::before`) and the h1 / subheadline text-shadows
are LOCKED (operator "PERFECT!"). This composition layer must never touch them. Bottom-
anchored compositions are deliberately safe because they sit inside the existing bottom-
darken band, so legibility is unchanged. Shipped v2.2.212 as one bounded variant
(operator: look before going wide); more compositions and a wider archetype map come after
visual review.

## Motion as a variety axis

Movement is a real source of variation within a shared Muse (shared muse across a root
or Cloud is a FEATURE for brand cohesion, not a bug). The scroll-reveal system (fade-up
sections, staggered cards, hero-excluded, no-JS-safe, reduced-motion-safe via
`!important`) has two intensities, chosen per business by
`museMotionLevel($muse, $bizTypes)`:

- **calm** (default) — the gentle reveal. Professional, civic, healthcare, most niches.
- **lively** (bold by invitation) — a stronger, snappier reveal, only when the Muse asks
  (`reading_json.pace === 'kinetic'` or an energetic `motion_vocabulary`) OR the business
  is an inherently energetic niche (bar, night_club, dance, gym, music, art_gallery,
  tattoo, brewery, event_venue, …).

`business-site.php` emits the lively tier behind a PHP conditional, with HARD CEILINGS
(section travel ≤40px, ≤0.95s; cards lift ≤22px + ≤3% scale; stagger ≤0.34s) and an
explicit NO list (no parallax, bounce, spin, 3D, blur, zoom-past-1.0, infinite loops,
hero motion, or horizontal drift). `prefers-reduced-motion` always wins. So "lively"
can never become tasteless or inaccessible.

## Tensions to respect

The contrast/readability/width guards (the v2.0 overhaul) can flatten creativity if
over-applied — see the creativity-regression thread in the site-quality backlog. The
goal is unique AND readable. Design-shape problems are governed by
[Design Standards](design-standards.md), not one-off CSS overrides.

Vocabulary: [Muse](../glossary/muse.md), [Family](../glossary/family.md),
[Niche](../glossary/niche.md).

---

# The Muse v2 layer, and the brief that feeds it

`systems/the-muse-v2-layer.md` · type: system · tags: muse, design, rendering, art-direction, moma

# The Muse v2 layer, and the brief that feeds it

Found by walking ONE site through the pipeline by hand: MoMA, chosen because if
the muse system has spirit it should show it there. Two findings, one of them
network-wide.

## A muse is only as good as its brief

MoMA's muse was Playfair Display with an electric-cyan accent - the default AI
answer for anything. The system prompt is not at fault; it is a genuinely good
art-direction brief that insists on grounding the palette in what the business
actually IS. The fault was the subject it was given:

```
Name: The Museum of Modern Art
Google types: tourist_attraction, art_gallery, art_museum, museum, ...
```

`museAutoAttachForBusiness()` selected six columns and forwarded five, while
`_autoMuseUserMessage()` had always been able to use the address, rating, review
count, website, price level and scraped copy - all in the row it had just read.
**Every bespoke muse on the network was designed from a name and a type list.**

Now the brief carries all of it, plus **review text**, which had never been used
and is the richest signal we hold: reviews name what a place is known for, in the
words of people who went. A MoMA reviewer writes "whether it be Starry Night or
models of schools". No list of Google types carries that.

Reviews are passed as **voice and subject evidence, explicitly not as fact** -
they are strangers' words and some are wrong. The design brief may learn what a
place is famous for; the copy pipeline still takes facts from the record.

Result on the same model and prompt: cyan/Playfair/editorial-magazine became
Bauhaus primaries (`#dc3a2a` / `#2a3adc` / `#f0c83a`) on bone white, bold sans,
**poster** archetype, swiss-modular grid, "circle square triangle". The lineage
MoMA canonized, from a brief that finally described the subject.

**The rule: pass everything free before blaming the model.** A generic brief
gets a generic answer, and it looks like a taste problem rather than a data one.

## The v2 layer had never rendered. To any site. Ever.

`museBodyAttrs()` expects a MUSE ROW and internally calls
`museComputeTokens(museReadingWithDefaults($muse))`. Its only caller,
`museEmitV2BodyAttrs()`, passes an already-computed TOKEN ARRAY.
`museReadingWithDefaults()` sees no `reading_json` key and returns **pure
defaults, silently** - correct behaviour for a v1 muse, catastrophic here.

```
passed tokens (what shipped):  gallery=grid paper=smooth btn=flat version="1"
passed the muse row (correct): grid=swiss-modular hero-comp=layered-blocks
                               motion=fade-up hover=invert header=minimal version="2"
```

`data-muse-version` gates every v2 rule (`body[data-muse-version="2"]`). So
type_pair, type_scale, grid_system, hero_composition, section_tones, image_crop,
image_overlay, motion_vocabulary, hover_style, header/footer style and
section_transition were all computed, written into the stylesheet, and matched
nothing. MoMA asked for `deco-display-geo` (Limelight) and rendered Inter.

**This is why bespoke sites look alike however good the reading is.** The
readings were never the problem.

## Turning it on is its own project

Fixing the signature enabled v2 on 653 live sites at once - a layer that, because
of this very bug, had never been looked at in a browser. It degraded them
instantly. Reverted in minutes.

`museV2RenderEnabled()` now gates it: `?musev2=1` or the `muse_v2_enabled`
platform setting, **off by default**. Opting out reproduces the previous output
exactly (the default token set), not just a zeroed version number - otherwise
`data-muse-grid` / `-motion` / `-hover` would still advertise themselves to any
rule keyed on them alone.

Fixed before rollout (v2.2.569): the section reveal. Nine variants each shipped
their own fade-in, and three faults ran through them - scripts that queried at
PARSE time (so they never saw sections below themselves), two variants sharing
one idempotency flag, and CSS that hid unconditionally so no-JS meant no reveal.
Now one shared `muse-section-reveal.php`, armed by a JS-added class, collected at
DOM-ready, with an uncancellable 4s watchdog.

**A measurement caution earned here.** I first read this as "live pages are
blank". It was not: sections DO reveal on genuine scroll. The blank captures came
from screenshotting mid-transition and from a synthetic scroll that jumped faster
than IntersectionObserver samples. **When instrumenting a time-based effect, the
observation method is part of the experiment** - wait out the transition, scroll
the way a person scrolls, and prefer the DOM's own numbers to a screenshot.

**`dz muse do=tokens biz=<id>`** prints every stage and the body attribute string
computed both ways. Built because from outside the browser you can only see the
result, and every failure in this pipeline is silent by design.

## The lesson that generalises

A feature that has never once executed in production is not a feature, it is a
hypothesis - and a well-tested unit can still be wired to nothing. **Ask what
proves a layer is live, not merely that its code is correct.** Here the proof was
one attribute on one tag, and nobody had ever looked at it.

## The real crawler risk, and the floor under every reveal

The page-level `.doozer-reveal section` system is scroll-driven with no watchdog.
Googlebot executes JavaScript but does not scroll, so measured on one live site
**7 of 10 sections were at `opacity: 0` for a rendering crawler**. Indexing beats
any effect: both reveals now show everything unconditionally after 4s.

**The rule for any effect that hides content:** it must prove it will un-hide it.
Gate the hiding on a JS-added class so no-JS never hides; collect targets at call
time, never at parse time; and keep a watchdog you never cancel.


## LIVE (v2.2.578) — and what it took

`muse_v2_enabled = 1`, 660 sites. **`dz muse do=v2 on=0` is the instant rollback**, no
deploy. It was used once during the release and worked in seconds.

`muse-motion.js` gates on `data-muse-version="2"`, so it had never executed in
production either. Switching v2 on ran it for the first time and exposed three
defects, all the same shape as the section-reveal bugs:

- **`initFadeUp` wrote INLINE `opacity: 0`.** Inline beats every stylesheet rule, so
  sections carrying both reveal classes still sat at zero. It also matched NESTED
  `[data-muse-section]` nodes, which can never intersect inside a hidden parent. Now it
  defers completely when the page-level reveal exists — **one reveal per page** — and
  targets top-level sections only, with an uncancellable watchdog.
- **`initTypewriter` destroyed the H1.** `textContent = ''` then retyped it over ~3s.
  Googlebot executes JS, so a crawler could snapshot an empty or partial headline; a
  stall left it truncated for good. Now the full text stays in the DOM and is revealed
  with a clip.
- **`initTiltWake` hid gallery cards** with no watchdog.

## Measuring a time-driven effect: the tab is part of the experiment

Twice during this release a page read as "10 of 11 sections hidden" and then healed the
moment I ran JavaScript against it. Cause: **a backgrounded browser tab throttles timers
and does not fire IntersectionObserver** — and running JS wakes it.

Check `document.visibilityState` and `document.hasFocus()` before believing any
measurement of a scroll- or timer-driven effect. Foreground the tab first. Screenshots
of a throttled tab are stale for the same reason.

Together with the earlier mistake (screenshotting mid-transition, scrolling faster than
IO samples), that is three separate occasions where **the observation method was the
bug**. Prefer the DOM's own state — is the reveal class present? — over a rendered
pixel, and when the two disagree, suspect the harness.

---

# The Network (the whole Doozer ecosystem)

`systems/the-network.md` · type: System · tags: ecosystem, network, products, referral-loop, breadth

> A map of every product in the Doozer world and how they interconnect into one referral loop, so an AI can see the breadth of the platform at a glance.

# The Network

Doozer is not one website. It is a **network of surfaces** that share one engine,
one data spine, and one referral loop. `renderNetworkSitesBar()`
(`includes/network-footer.php`) is the single source of ecosystem naming/order; a
`soon` flag flips a product live. Everything below is built by BerrySmart (Jon
Berry, solo founder) on the same [architecture](architecture.md).

## The surfaces

- **Doozer** (`doozer.work`) — the platform: landing page + admin console + the
  engine that generates every business site. Free tier = a templated info page the
  business owns; paid **Doozer+** = a bespoke full site. See
  [the two-tier model](../decisions/two-tier-model.md).
- **Business sites** — the leaves. One generated single-page site per business,
  served on a [root](../processes/site-creation.md) subdomain (e.g.
  `*.elks.club`, `*.westmilford.biz`, `*.vsso.net`).
- **Roots** — a wildcard domain whose apex is a directory of its member businesses,
  optionally with a bespoke identity layer (see the [Elks root theme](elks-root-theme.md)).
- **NearByMeNow** (`nearbymenow.com`) — the [directory/traffic layer](nearbymenow.md):
  a public "what's near me" surface that indexes the whole fleet and feeds visitors
  back to business sites. The active side of the referral loop.
- **Clouds** (`doozer.cloud`, `my.doozer.cloud`) — [curated directories](clouds.md)
  by niche/region/criteria, plus each user's personal cloud.
- **Vertical Directories** — niche directories built on shared data, e.g.
  [livemusictonight.com](livemusictonight.md).
- **LeadBuilder** — the [lead-intelligence](lead-capture.md) destination; every
  captured lead and intent signal flows here to become revenue.
- **GoHe.re** (gatherings/events), **AwesomeAddresses** (homes), **wuz.ai**
  (check-ins) — [roadmap surfaces](location-data-enrichment.md) on the same spine.

## The loop (why it compounds)

Business sites are found via search + NBMN + Clouds → visitors act (call, message,
visit) → those actions become [leads and signals](lead-capture.md) → signals drive
[Prospector](prospector.md) discovery and [Marketing](marketing.md) → more sites →
more directory depth → more traffic. Cross-surface identity is always by ID via
[Magellan](../decisions/magellan-id-mapping.md), never by name-guessing. The
economics of this loop are [the value model](../decisions/the-value-model.md); the
mechanics are [the flywheel](the-flywheel.md).

---

# The Niche Color System

`systems/niche-color-system.md` · type: System · tags: color, niche, category, design, primitive

> One source of category color for the whole network — the design primitive that makes cards, pills, spines, and markers coherent across every surface.

# The Niche Color System

`nicheColor()` (`includes/niche-colors.php`) is the single source of category
color network-wide. Category → hue, niche → shade within that hue. Every surface
that shows a business or a niche — the [shared business card](shared-business-card.md)
(spine + category pill), directory chips, map markers, category pages — draws from
this ONE function, so a "food" place looks like food everywhere and an
"automotive" place looks like automotive everywhere, without per-surface color
logic.

## Why it's a primitive, not a detail

Coherent category color is what makes the [network](the-network.md) feel like one
system instead of many pages. It is a design PRIMITIVE (like the
[design standards](design-standards.md) registry and the shared card): change the
mapping in one place and the whole network updates. The star rating uses a fixed
gold (`#f5a623`) so ratings read consistently regardless of category.

Consuming code destructures `[$accent, $soft]` (a strong accent + a soft tint for
backgrounds/pills). New surfaces that display a niche must call `nicheColor()`
rather than inventing a palette — that is how a new card, a new directory view, or
the "More places nearby" cards ([network growth](network-growth.md)) stay on-system
for free. Next horizons: category-page theming and colored map markers driven by
the same source.

---

# The Photo Pipeline (harvest, store, serve)

`systems/photo-pipeline.md` · type: System · tags: photos, images, cache, spaces, cdn, cost, places

> How business photographs are referenced from Google, bought once on first view, stored as objects on DO Spaces, and served back through a predictable cache key that lets any page ask "do we already own this?" before spending.

# The Photo Pipeline

One sentence: **we store photo *references* for free, buy the bytes once when a real
person first looks, keep them forever as public objects, and every URL is derived from
a key we can predict — so any page can ask "do we already own this?" before spending.**

Related: [Architecture](architecture.md) · [Source of Truth Cache](source-of-truth-cache.md) · [The Observed World](../decisions/the-observed-world.md)

---

## 1. Harvest — we take the reference, not the picture

When a business is discovered, Google Places returns photo handles. Those land in
`businesses.photos` (JSON) in one of two shapes:

| API | field | looks like |
|---|---|---|
| Places API (New) | `name` | `places/ChIJ…/photos/AaVGc3n…` |
| Places API (Old) | `photo_reference` | `AaVGc3n…` |

**Nothing is downloaded at this point.** A reference costs nothing to hold, and most
businesses in the network are never opened by anybody. This is what makes par-created
skeletons and the directory's blueprint plates cheap: we know a place has photographs
without having paid for them.

## 2. Choose — which photo to show

`resolveBusinessImageUrl($row, $width)` in `business-image-resolver.php` is the
**canonical picker**, used by every surface so quality matches everywhere. It returns
`['url' => …, 'is_logo' => bool]`, preferring, in order: an already-resolved
`photo_url`, then Google photos, then scraped / deep-research images, then the logo.
When it returns nothing the caller draws a category tile instead.

`business_photo_meta` (**58,545 rows**) holds per-photo vision analysis — subject
class, aesthetic, quality, caption — for ranking and hero selection.

## 3. Derive the URL — never link Google directly

`googlePlacePhotoProxyUrl($photo, $maxWidth)` builds a URL on our own host:

```
https://<any-doozer-host>/?e=photo&name=<urlencoded reference>&w=<W>&h=<H>
```

Linking `googleapis.com` directly would leak the API key and bill us for every view by
every visitor and every bot. The proxy is what makes the rest of this possible.

## 4. The cache key — the load-bearing detail

```php
$cacheFilename = generateCacheFilename($cacheKey, 'google', ['w' => $w, 'h' => $h]);
// google_<md5( photoName . json_encode(['w'=>W,'h'=>H]) )>.jpg
```

**The width and height are part of the key.** A size nobody has requested before is a
guaranteed cache miss and a fresh purchase — and under warm-only rendering it means the
photo silently never appears, no matter how many copies of that same image we already
own at other sizes.

Reuse the sizes the network already accumulates:

| use | width |
|---|---|
| directory + nearby thumbnails | **400** |
| business cards | 600 |
| hero | 1200 |

## 5. Serve — the order `?e=photo` tries

1. **Registry first.** `imageRegistryLookup($cacheFilename)`; if `status = 'cdn'`,
   302 straight to `cdn_url`. No local file needed. Gated by the platform setting
   `image_serve_from_cdn` (currently `1`).
2. **Local disk cache** — `cache/images/<cache_key>` (path in `image_cache_path`).
3. **Negative cache** — `neg_<cache_key>`. Stale Google references return HTTP 400
   forever; the marker stops us re-buying them on every pageview.
   *Ordering matters: the positive cache is checked first, or one transient 4xx hides a
   perfectly good image behind a negative marker.*
4. **Fetch from Google** — New API `places.googleapis.com/v1/<name>/media`, or Old API
   `maps.googleapis.com/maps/api/place/photo`.
5. **Determine the content type by SNIFFING MAGIC BYTES**, not by trusting the upstream
   header (see gotchas).
6. **Serve the visitor, then persist.** After `fastcgi_finish_request()` — so the
   visitor waits for none of it — the bytes are written to Spaces and recorded in the
   registry. Gated by `image_spaces_dualwrite`.

## 6. Store — objects on DO Spaces

- Bucket, region and CDN base live in `platform_settings`: `spaces_bucket`,
  `spaces_cdn_base`. Credentials are `spaces_key` / `spaces_secret` in `api_keys`.
- Object key: **`images/<cache_key>`**
- Written `public-read` with `Cache-Control: public, max-age=31536000, immutable`
- Public URL: `<spaces_cdn_base>/images/<cache_key>`

## 7. The registry — `image_assets`

**128,168 rows: 113,506 on the CDN, 14,662 negative.**

| column | meaning |
|---|---|
| `cache_key` | `google_<md5>.jpg` — the join key for everything |
| `spaces_key` | `images/<cache_key>` |
| `cdn_url` | the public URL |
| `content_type` | what the object is served as |
| `bytes`, `source_type` | size and origin |
| `status` | `cdn` (we own it) / `negative` (known-bad reference) |
| `cdn_uploaded_at`, `ct_fixed_at` | when stored, when its type was repaired |

## 8. Knowing what we already own — the warm-only rule

Because the key is predictable from the URL alone, a page can ask about a whole screen
of photos in one query before rendering any of them:

```php
dzcPhotoCacheKey($url)          // reverse the URL back to its cache key
dzcPrimePhotoWarmth([$urls])    // ONE batched lookup for the whole page
dzcPhotoIsWarm($url)            // free thereafter
```

Photos we own are shown; photos we do not are replaced by the category plate, and the
first real visit to that business buys them for everyone after. Used by the NBMN
directory, the NBMN home list, the neighborhood block on every business site, and the
JSON-LD emitter.

**It fails open**: if the registry is unreachable everything is treated as warm. A
directory that silently loses its imagery to a database hiccup is a worse failure than
one that spends a little.

## 9. Gotchas, all of them paid for

1. **Width is in the key.** Asking for 160px to fill a 44px tile meant every card fell
   back to a plate while its photo sat on the CDN at 400.
2. **Never trust the upstream `Content-Type`.** Read the *last* header across redirects
   and sniff the magic bytes. **32,223 objects were stored as `application/json`** —
   valid JPEGs that browsers refused to render, showing blank boxes across the network
   for months.
3. **Correcting an object does not correct the cached response.** The CDN edge had
   already cached the bad header under `immutable, max-age=31536000`. Healing the object
   changes nothing a browser sees. The registry-first 302 now appends
   `?v=<hash of ct_fixed_at>` for healed objects only — a new edge cache key exactly
   once, leaving every never-healed image on its warm URL.
4. **Structured data must publish warm images only.** `?e=photo` buys on first request
   and 83% of network traffic is bots; a cold photo URL in JSON-LD is an invitation for
   crawlers to spend money on pictures no person asked to see.
5. **A stamped row is not a fixed row.** An early version of the healer stamped
   `ct_fixed_at` without correcting `content_type`; those rows looked done and nothing
   retried them. `dz imgct reset=1` clears the stamp.

## 10. Operator commands

```
dz imgct [limit=] [rounds=] [all=1] [probe=1] [reset=1]   # repair content types
dz photo_audit                                            # find broken/missing images
dz photo_heal                                             # re-resolve and re-upload
```

---

# The Section Framework

`systems/section-framework.md` · type: System · tags: render, sections, muse

> How a business page is assembled from pipeline section slots and per-variant partials a Muse selects.

# The Section Framework

A business page (`includes/business-site.php`) is a sequence of section slots (hero,
about, services, why-choose-us, menu, products, photos, reviews, faq, hours, contact).
For each slot the active [Muse](../glossary/muse.md) may pick a variant; otherwise a
classic fallback renders.

- `includes/muse-sections.php` — `museRenderSection($slug, $variant, ...)` dispatches to
  a partial at `includes/sections/<slug>/<variant>.php`, with a taste-defense gate that
  downgrades to `classic` when a variant's prerequisites are not met.
- Variants share container classes (`.services-intro`, `.about-content`,
  `.muse-alt-prose`) so global prose rules apply across all of them.
- All prose flows through `renderProse()` (`includes/prose-render.php`), which enforces
  [Design Standards](design-standards.md).

The roadmap (per the grand vision) is per-business-type sections: restaurants get
menus and daily specials, venues get calendars and commerce. New section types become
new slots + partials here.

---

# The Shared Business Card

`systems/shared-business-card.md` · type: System · tags: directory, card, ui

> The one card renderer used across NBMN, cloud, and root directories, with a pluggable toolbar and category-aware call intent.

# The Shared Business Card

`includes/business-card.php` is the ONE card renderer network-wide (it replaced four
drifted implementations). `businessCardData($row)` normalizes any listing shape;
`renderBusinessCard($card, $opts)` emits a CSS-self-contained `dzc-*` card that works on
Tailwind root pages and vanilla NBMN alike; `renderBusinessCardAssets()` ships the
styles + the one canonical image fallback + the call popup.

## Key properties

- Image via the canonical `resolveBusinessImageUrl()` everywhere (killed the NBMN blank
  image bug). The card self-loads its dependencies (image resolver, niche helpers,
  call-intent catalog) because directory surfaces do not preload them.
- Pluggable toolbar `renderBusinessCardToolbar` — default actions Call + Save; check-in
  is contextual (map/near-me), not on generic directory cards.
- Call uses category-aware [call intent](#call-intent).
- Icons are neutral white on the dark translucent circle; COLOR is reserved for STATE
  (saved heart red, etc.), never for the buttons.

Rendered network-wide: NBMN, [Cloud](../glossary/cloud.md), and root directories.

## Call intent

`includes/call-intent-categories.php` maps a business's category/niche to a small set
of intent chips (restaurant to "Order takeout"; flooring to "Get a flooring quote").
The same catalog powers the card Call popup and the business-site Call Now popup.
Records to `lead_intents` and bridges to LeadBuilder. See [the flywheel](the-flywheel.md).

---

# The Weather Widget

`systems/weather-widget.md` · type: System · tags: weather, business-site, enrichment, visit-timing, open-meteo

> A non-intrusive "plan your visit" weather card on business sites — current conditions plus a 7-day forecast for the business's own location, free by default.

# The Weather Widget

Every Doozer business site can show a small, calm weather card near its Hours
section: **current conditions plus a 7-day forecast for the business's own
location.** It is a helpful visit-timing signal — "is this a good day to go?" —
and never a popup or an interruption. It is part of the broader push to make a
site more than a static listing (see
[per-location data enrichment](location-data-enrichment.md)).

## How it works

`includes/weather.php` is the whole engine:

- `weatherFetch($lat, $lng)` is **cache-only and never blocks the render.** On a
  cache hit it returns the normalized shape (`current` + a 7-day `daily` array)
  regardless of provider. On a miss it writes an empty dedupe marker, returns
  `null` (no card this paint), and hands the real HTTP call to
  `weatherScheduleWarm()`, which runs it *after* the response via
  `register_shutdown_function` + `fastcgi_finish_request()`. The next visitor
  inside the 2h TTL gets the warmed value. This is deliberate: the live
  Open-Meteo/OWM call once ran inside the render (`weather.php:82`) and the FPM
  slow log named it the top request-holder and a [521](the-521-root-cause.md)
  amplifier. A third-party weather API can never stall a page again (v2.2.494).
- `renderWeatherWidget($ctx)` renders the card: current temperature + condition,
  an optional place label (the city, parsed from `formatted_address`), and a
  7-day strip of day / icon / high·low. Its CSS is scoped and
  `!important`-hardened so arbitrary [muse](muse-system.md) rules cannot restyle
  it (the same chrome-hardening rule the sponsor bar and dialogs follow).
- `weatherEnabled()` gates the whole thing on a platform setting.

`includes/business-site.php` renders it right after the Hours section, deriving
coordinates from the same geometry / lat-lng fallback the map uses. It renders
nothing at all when the widget is disabled, coordinates are missing, or the fetch
returns no data — a missing card is always better than a broken one.

## Provider and cost

The default provider is **Open-Meteo: free, no API key, worldwide.** WMO weather
codes map to calm inline-SVG glyphs (no emoji clipart, per the
[icon taste rule](../decisions/icon-taste-rule.md)). Results cache for about two
hours per ~1 km location bucket, so the only work is a cache miss and the running
cost is zero — consistent with the platform's
[cache-aggressively, minimize-API-cost discipline](../decisions/the-value-model.md).

An operator can optionally set an **OpenWeatherMap** key (Admin → Platform
Settings → Weather) to use that provider instead; the code aggregates OWM's free
5-day/3-hourly feed into the same daily shape. No key is required for the widget
to work.

## Admin

Admin → Platform Settings → **Weather** tab: an enable toggle (on by default) and
the optional OpenWeatherMap key field, with a plain-language note that a key is
not needed. Stored in `platform_settings` under category `weather`.

## Roadmap

This shipped from the roadmap idea of a visit-timing signal. The further vision:
feed weather into [Gatherings](gatherings-and-social.md) so any place + time can
answer "what will it be like when I'm there," and surface it as a signal in the
events/marquee surfaces. See [[project_weather_widget]].

---

# Two gates, and the category catalog

`systems/two-gates-and-the-catalog.md` · type: system · tags: nearbymenow, par-create, visibility, niche, seo, directory

# Two gates, and the category catalog

## One flag was answering two questions

`domains.include_in_sitemap` gated the sitemap AND every directory listing query.
So "do not submit this to Google" and "do not show this to a person browsing
NearByMeNow" were the same switch. There is no setting for the state par-create
was designed to produce: a real site, worth showing to the human who conjured
it, not yet worth asking Google to index.

Now:

| flag | question | posture |
|---|---|---|
| `include_in_directory` | can a person find this in NBMN? | **generous** - real content about a real, located place |
| `include_in_sitemap` | do we ask Google to index it? | **conservative** - thin content stays out |

`siteDirectoryGateSql($alias)` is the fragment every listing query uses; it falls
back to the old flag until the migration has run, so there is no broken window.
`sitemap.php` and `indexnow.php` stay on `include_in_sitemap` deliberately - they
are the index side.

**A new skeleton must close both gates explicitly.** `include_in_directory`
defaults to 1 so that thousands of existing rows keep working; a skeleton with no
content would therefore list itself. `par-create.php` writes 0,0.

## The promotion step did not exist

The rule "skeletons are promoted on enrichment" was written down, taught to every
session, and never implemented. Par-create inserted `include_in_sitemap = 0` and
the only statement anywhere that set it back to 1 was a manual bulk action in the
admin domain list. 662 sites were enriched (for $1.11, in one batch) and stayed
invisible.

`sitePromoteOnEnrichment($businessId)` now runs from `saveGeneratedContent()` -
the single place all generated content is written. Directory visibility is earned
by having substantive content; index eligibility is a separate, stricter test
(`siteIndexEligible`: real prose + an image + an address + coordinates).

**A documented rule with no code behind it is indistinguishable from a bug, and
harder to find, because everyone believes it already works.**

## The catalog: 57% of the network had no page

1,658 of 2,929 listable businesses appeared on no niche page. Not bad data - the
`niche_mappings` table simply had no row for the type they carry, so no page
existed to put them on. The largest group was `association_or_organization` at
1,627: **the lodges, which drive 68% of human traffic, had no category.**

`dz nichecatalog [seed=1 [live=1]]` is the seeder; `dz nichecatalog` alone is the
standing gap report. Two kinds of entry, and the distinction matters:

- **New niches** - a real category with no page (associations, event-venues,
  live-music, theaters, stadiums, golf-courses, sports-clubs, breweries,
  wineries, caterers, medical-clinics, chiropractors, nonprofits, farms).
- **Aliases** - a Google type that is a sub-kind of a niche we already have.
  `mexican_restaurant` is a restaurant. It should not get its own page; it
  should be findable on the restaurants page and rank there as an exact match.

Orphans went **1,658 -> 4**.

Google's broadest tags (`service`, `consultant`, `health`, `finance`) route to
the catch-all `businesses` page - a page beats being unreachable, and a wrong
category would be worse than a vague one. `locality` and `food` are excluded on
purpose: one is a town, the other is a modifier.

## Keeping new sites categorized

`backfillBusinessFlatColumns()` chose the first non-generic Google type, so
`['consultant','accounting','finance']` filed under `consultant` - no page - while
`accounting` sat right behind it. It now prefers **the earliest type that resolves
to a known niche**, falling back to the old behaviour so a genuinely new category
is still recorded rather than dropped. Anything unmapped surfaces in
`dz nichecatalog` instead of vanishing.

## Gotcha: a probe guard that ate real niches

The infrastructure-probe denylist (v2.2.563) treated `^[a-z0-9]{12,}$` as a
generated hash. That matches any all-letter word of twelve characters or more, so
`associations` (exactly 12), `chiropractors`, `veterinarians`, `electricians`,
`supermarkets` and `universities` all 404'd on their own subdomains. A real hash
mixes in digits; a niche slug never does. **A guard written against one shape of
garbage will catch real data if the shape is described loosely enough** - and it
fails silently, because a 404 on a page nobody has linked yet looks like nothing.

---

# event-feeds

`systems/event-feeds.md` · type: Systems

# Event Feeds — Marquee and the multi-source engine

**What:** the pattern that builds the network BACKWARDS from ticketed/scheduled
events. An external feed lists events at venues; each venue becomes a Google-Places-
backed Doozer site, enriched with its upcoming events, turned into a Global Gathering,
with affiliate ticket links. Marquee (Ticketmaster) is the first live instance; the
plan is to generalize it into one engine with per-source adapters.

**The pipeline (source-agnostic after the fetch):**
feed fetch -> normalize -> match venue to an existing business (accuracy-first) OR
build it (`resolvePlaceToEcosystem`) -> stamp the external venue id (Magellan link)
-> `business-events.php` renders events on the site -> gathering + affiliate link.

**The universal join key is the Google place_id.** Every feed's venue resolves to one,
so multiple feeds enrich the SAME site instead of duplicating. This is why the
accuracy-first matcher matters as feeds multiply.

**Marquee today (`includes/marquee-engine.php`, `admin-marquee.php`):**
- `marqueeTmFetch` (Discovery API v2), `marqueeNormalizeTm`, `marqueeIngest`.
- `marqueeMatchVenue($name,$city,$state,$lat,$lng,$tmVenueId)` — accuracy-first
  (v2.2.433): tm_venue_id -> physical geo (name-confirmed, never geo-only, generic
  venue words excluded) -> normalized name -> legacy. Unit-tested 7/7 on real
  collisions. Ingest stamps `businesses.tm_venue_id` on every match (enriches existing
  sites, no rebuild).
- **Trending (US)** (v2.2.435): national sweep, Ticketmaster relevance order
  (`sort=relevance,desc`), no city. Skims the hottest venues everywhere; widen into
  quieter markets over time.
- **Discovered venues** card = the build list (`marqueeRecentVenues`). Was silently
  empty until v2.2.431 (an `ORDER BY g.site_domain` alias bug — a correlated-subquery
  alias can't be table-qualified in ORDER BY).
- `dz marquee [do=status|repair|backfill|trending]`. `repair` re-links orphans;
  `backfill` re-runs the matcher over stored events.

**Business-first enrichment (v2.2.438) — the complement to feed-first:** a venue site
built OUTSIDE Marquee (More Places Nearby / Prospector) has no events, because Marquee is
feed-first (events arrive only when a sweep covers that venue). `marqueeEnrichBusiness($bizId)`
probes the feeds BY the business's own name + coordinates and links its shows to the existing
site. Reuses everything (marqueeIngest keyword+geo → accuracy match → stamp → site renders);
1 TM call (+1 SeatGeek) per business, 3-day freshness guard. `marqueeEnrichScan($limit)` =
niche-gated backfill of venue sites that have a site but no upcoming events. `dz marquee
do=enrich biz=<id>` / `do=enrich-scan [limit=]`. THE PRINCIPLE: every enrichment feed is a
business-first probe keyed on identity (name+geo+niche) whose results route to the right
section; Marquee/events is the first.

**GOTCHAS (learned the hard way):**
- **Apostrophe orphans (v2.2.434):** a build created the business + marked
  `pr_discoveries.status='site_created'` but the domain never persisted, because
  `linkDomainToBusiness()` returned true without verifying the INSERT. Every rebuild
  then dead-ended on the "already created" guard. Fix: the link now VERIFIES the row +
  logs the real error; the guard SELF-HEALS (only blocks when a live domain exists).
- Ingest silently swallowed per-row insert errors -> "Ingested 0 of N" with no reason;
  now surfaces skip count + first error and does not auto-reload over a zero-store.

**Multi-source (v2.2.436):** `marqueeIngest` takes `opts['source']` (ticketmaster |
seatgeek) and dispatches the adapter; `marquee_events.source` + `marquee_sweeps.source`
are bound, not hardcoded. Cross-feed dedup is by Google place_id (a venue built from TM
is ENRICHED by SeatGeek, never duplicated).

**SeatGeek — feed #2, SHIPPED (`includes/seatgeek.php`):** the affiliate revenue play
(~$11/sale vs TM's thin margins). Pure adapter: `seatGeekFetch`/`seatGeekNormalize`
emit the same flat shape as `marqueeNormalizeTm`. Key = `seatgeek` client_id in api_keys
(free at seatgeek.com/build). Affiliate `aid` in `platform_settings seatgeek.affiliate_id`,
wrapped onto ticket urls when set (blank = links go straight to SeatGeek). Its native
`score` is the trending order (`sort=score.desc`). Admin -> Marquee has a SeatGeek card +
a Source selector on Ingest/Trending. `dz seatgeek [do=test|trending|ingest]`.

**Generalization plan:** see `docs/features/EVENT_FEEDS_ROADMAP.md`. FeedSource
interface; Ticketmaster + SeatGeek are adapters #1/#2. **Facebook events are DEAD**
(no compliant API). **Movies (Popcorn)** = theaters from Places + Fandango affiliate
deep-links now; paid showtimes feed only on demand. A generic **ICS/RSS adapter** is
the on-brand long-tail win (civic/library/campus calendars -> gatherings). Keep it ONE
engine with adapters, not forked per vertical.

Links: [[project_marquee_status]], the Observed World, GoHe.re gatherings,
[[project_monetization_plan]] (per-source affiliate ledger).

---

# machi

`systems/machi.md` · type: Systems

# Machi — the marketing brain

**What:** the single strategist every outbound marketing touch flows through. She
reads the observed world, decides WHO needs a postcard, picks the MESSAGE most
likely to convert them, has the AI postcard engine paint a gated card, and learns
from what actually happened (scans / visits / claims / Doozer+ conversions).
Winning angles get sent more; losers fade. Named for a strategist who "smells
success in the wind." Shipped v2.2.380.

**Engine — `includes/machi.php`**
- `machiScoreBusiness($biz)` → 0-100. NEED (weak/no own website), FIT
  (`machiNicheTier` — trades/health/auto/legal/realtor = tier 3, active marketers
  food/beauty/fitness = tier 2), PROOF + ability to pay (review volume, rating,
  price_level). Returns score + segment + reasons.
- `machiRankTargets($opts)` → the "these need a postcard" list. SQL preconditions:
  active domain (a real screenshot exists), OPERATIONAL, not claimed
  (`admin_email` empty), not already converted; default view = untouched, or
  `follow_up` for businesses past cooldown (≤3 touches). Scored + sorted in PHP.
- `machiPickVariant($businessId,$seg,$peek,$exclude)` → the angle. Weighted-random
  by `machiVariantScore` (Bayesian-smoothed conversion value + scan/claim signal) ×
  segment affinity, EXCLUDING angles already SENT to this business (follow-ups
  differ) and any explicit exclude (a rerun's current angle).
- `machiGenerateForTarget($businessId,$opts)` → pick angle → `postcardAiRun` with
  `campaign='machi'`, `message`=chosen copy, `variant_id`. NO touch yet (previews
  and reruns must not inflate the sequence); the angle is stored on the design.
- `machiFullSend($designId,$opts['live'])` → `postcardAiSend` live, then creates the
  `machi_touches` row (the send is the real event), touch_no = prior sent + 1,
  then `machiSyncOutcomes`.
- `machiSyncOutcomes()` → for each SENT touch pull `postcardAiTracking` + claim +
  `machiBusinessConverted` (premium owner by email match), recompute each
  variant's rolling stats from `machi_touches` (idempotent).
- `machiPerformance()` (funnel + variant leaderboard + spend from
  `ai_generation_history` content_type `postcard_ai` + thanksio sends + revenue =
  conversions × `machi_conversion_value` default 99 + ROI). `machiMarketScan()` =
  which segments pay back.

**Tables (self-creating):** `machi_variants` (8 seeded angles: ready / leads /
found / free / proof / local / pens / ahead; each with rolling
times_sent/scans/visits/claims/conversions) and `machi_touches` (per-business send
history + learning signal). `postcard_designs` gained `variant_id` + `angle`.

**Dashboard — `includes/admin-machi.php`** (Story → Machi, route `machi`): funnel +
spend + revenue + ROI; ranked target cards (score / why / segment / lead angle) →
Generate → gated preview appears in-card → **Full send** (live=mail) / **Rerun**
(fresh angle) / Reject; "Winning messages" leaderboard + "Market scan"; filters by
segment / state / min score / follow-ups-due / untouched-only.

**Creative uses the operator's Image Generation config** now: `postcardAiGenerate`
routes through `aiImageProviderDefault()` — OpenAI GPT Image via `/images/edits`
(screenshot + logo as real reference images) OR Gemini generateContent — so no
separate key is needed when OpenAI is set, and cost lands in Analytics & Costs at
the configured per-image price. See [[ai-postcards]], [[claim-codes]],
[[thanksio]], [[the-observed-world]].

**Dev API:** `dz machi` — `rank` / `gen b=` / `send id= live=1 confirm=1` / `sync`
/ `perf` / `market` / `seed`.

**Verified live v2.2.380:** seed + rank return sensible targets (established
high-review businesses first, angles varied, trades score tier-3). Real sends +
learning accrue once postcards mail. NEXT: scheduled auto-drafting of top targets
into the review queue (traffic-driven, like the job runner); agent-authored new
angles into `machi_variants` (source='agent'); market/geo selection surfacing
high-ad-spend metros.

---

# Category-aware call intent

`craft/category-aware-call-intent.md` · type: System · tags: craft, call-intent, lead-capture, chrome-primitive, categories

> The tap-to-call chips are tailored to why you'd actually call THIS kind of business — reserve a table at a restaurant, get a quote from a plumber, check if the car is ready at a shop.

# Category-aware call intent

When a visitor taps **Call** on a Doozer site, most sites would just dial the number.
That is a wasted moment. The reason a person calls a business is *specific to what the
business is*, and that reason is a lead signal worth capturing without adding a single
step of friction.

So the call action opens a tiny set of **intent chips** — and the chips are different
for every kind of business, because the reasons to call are different:

- A **restaurant**: "Order takeout" · "Reserve a table" · "Today's hours"
- A **plumber** (services): "Get a quote" · "Book a visit" · "Is this urgent?"
- An **auto shop**: "Get a repair estimate" · "Book service" · "Is my car ready?"
- A **salon** (beauty): "Book an appointment" · "Any openings today?" · "Ask about pricing"
- A **doctor** (health): "Book an appointment" · "I'm a new patient" · "Ask about insurance"

Nobody has to think about which chip fits; the right three are already there. The
visitor taps the one that matches their head, then the call connects. We learn *why*
they called before they say a word.

## Why this is the exemplar

It is the pattern we want more of: a **chrome primitive** (a small, shared piece of
interface) that is *aware of the thing it is attached to.* The same tap, on two
different businesses, produces two different, perfectly-fitted experiences, and each
tap is a typed lead signal. It costs the visitor nothing and it makes the site feel
like it understands the business. That is the whole Doozer thesis in one control.

## How it resolves (niche wins, category backs it up)

`includes/call-intent-categories.php` has two layers. `callIntentChipsForNiche()`
checks for an override keyed to the specific niche (pizza, roofers, coffee shops have
their own hand-tuned chips). If there is no niche override, it falls back to
`callIntentChipsForCategory()` for the broad category (food, services, health, beauty,
automotive, and so on), and finally to a sane general set. So a niche can be as
special as it deserves to be, and everything still has a good default. This is the
same category-then-niche cascade the [Niche Color System](../systems/niche-color-system.md)
uses for color.

## Where the signal goes

Taps are lead intent. They feed `lead_intents` and bridge into
[LeadBuilder](../systems/leadbuilder.md), and the same chip vocabulary powers the
business-site "Call Now" and the [shared business card](../systems/shared-business-card.md)
Call popup, so the experience is identical everywhere the business appears. See
[Lead Capture](../systems/lead-capture.md). Related craft:
[fix the source, not the symptom](fix-the-source-not-the-symptom.md) (these chips are
authored data, not a scraped guess).

---

# Cross-root identity without third-party cookies

`craft/cross-root-identity.md` · type: System · tags: craft, identity, auth, cookies, sso, cross-domain

> One login has to work across doozer.work, elks.club, nearbymenow.com and every other root — but third-party cookies are dead. So identity rides a first-party token handoff, not a shared cookie.

# Cross-root identity without third-party cookies

Doozer is one product spread across many *registrable* domains: doozer.work,
elks.club, nearbymenow.com, doozer.cloud, and the wildcard roots. A person who signs in
on one should be recognized on the others. The textbook answer is a shared session
cookie on a common parent domain. That answer is gone: browsers now partition or block
third-party cookies, so a cookie set for doozer.work is simply not readable from
elks.club. Anything built on cross-site cookies is already broken.

## The move: hand the token across, first-party each time

Nothing is shared ambiently. Identity is *handed* from one root to the next through the
link the user actually follows, and re-established as first-party on arrival.

- **Users:** an ecosystem link is rewritten by `doozerEcoLink($url)` to carry a
  short-lived `?dzauth=` token. The destination root reads it via `get_user_token` /
  `adopt_user_token`, mints its *own* first-party session, and drops the token from the
  URL. The user lands signed in, on a cookie that root is allowed to set.
- **Admins:** the admin JWT is fetched via `get_admin_jwt` and kept in `localStorage`,
  which is per-origin. Each standalone surface bootstraps admin state from its own
  storage rather than expecting a shared cookie
  (see [admin tools must never reach regular users](../systems/admin-health-tools.md)).

## Why it is craft

The non-obvious part is refusing the shared-cookie mental model entirely and treating
every root as a *separate origin that must re-earn the session on entry.* The token
lives in the one thing that does cross origins cleanly, the link the user clicks, and
never in ambient storage that the browser will quietly wall off. Any new root (NBMN
today, GoHe.re tomorrow) is not "wired into the cookie"; it just has to implement the
handoff endpoints. It is more moving parts than a cookie and it is the version that
still works in a partitioned-cookie world. Same instinct as
[implicit verification](../decisions/implicit-verification.md): carry exactly the
minimum identity signal, exactly when it is needed.

---

# Decision vs delivery (the muse was almost never the problem)

`craft/decision-vs-delivery.md` · type: Craft · tags: muse, design, delivery, lesson, debugging

> Nine defects found in one week where the design system chose well and the render layer quietly discarded the choice. The shapes these bugs take, why they never error, and how to find them.

# Decision vs delivery

When Jon says the sites look ugly, boring, or samey, the instinct is to re-prompt the
model. **Do not start there.** Across a full week of work in September 2026, every single
cause was a *narrowing between the decision and the pixels*. The muse chose well and the
render layer threw the choice away. Nothing errored. Results were just quietly smaller.

> **Print the decision, then trace what survived to the pixels.**

That is the whole method. `dz muse do=brief biz=<id>` prints the exact brief the generator
was given; the muse reading holds what it answered; the live CSS holds what arrived. The
bug is always in a gap between two of those three, and it is always silent.

## The five shapes

**1. A vocabulary narrower than the thing it describes.** `display_style` allowed ten
values while the 100 direction families speak about thirty-nine (`humanist-warm`,
`letterpress-roman`, `wood-type-revival`). Unmatched values did not fail — they fell back
to `<family>:modern`, so a muse asking for *humanist-warm* was served **Inter**. Five
typefaces covered 73% of the network; Playfair and Cormorant alone were 37%. Same shape in
`section_variants`, where `why-choose-us` offered exactly two values, so 742 of 864 muses
"chose" the same one.

> A stored value is only a decision if the schema offered an alternative. Where it did
> not, treat it as a default and let the current default win. `reading_version` is the
> marker for this — see the deferral rule in `muse-sections.php`.

**2. A fallback that lands on the most generic option is worse than a crash.** A crash
gets fixed. A silent fall-through to Inter looks like a choice, survives review, and
teaches everyone that the model has no taste.

**3. Positional slots that assume a role.** `--muse-neutral-2` meant "second after a
luminance sort". 91% of palettes carry exactly two neutrals — paper and ink — so slot 2
*was* the ink, and every rule grounding an element in it painted black-on-black. Dollywood
shipped a 271px footer, fully laid out, `opacity: 1`, every glyph invisible.

> **Test the property you need, never the index.** A surface slot has to be able to carry
> the ink; ask it, do not assume it. The same guard is why `--muse-fg` is now deepened
> along its own hue when the darkest neutral cannot carry text (28% of sampled muses).

**4. Two correct systems colliding.** Hero *treatments* pair `--hero-panel-bg` with an
AA-computed `--hero-panel-text`; hero *compositions* force white for a scrimmed photo.
Both applied; the composition selector carried one extra element, so it won — white on a
`#fafaf6` panel, **1.02:1**. Neither system was wrong alone.

> **When another system has already decided ground and ink together, stand down** — for
> colour *and* layout. Sections that paint their own background declare
> `data-muse-own-ground` so the alternating tone wash leaves them alone.

**5. Chrome that depends on an optional layer.** The hero CTA's colour was pinned inline
and its *shape* delegated to `museHeroCtaCss`, which only runs when a muse exists.
`elks-root.php` styles it for its own theme. Sites with neither — 1,154 of them — rendered
`display:inline; padding:0; text-decoration:underline`, a 92×22 underlined text link under
the 44px tap-target minimum.

> If a base element needs chrome, ship the chrome with the element at low specificity.
> Optional layers may override; they may not be the only provider.

## Two more that generalise

**Hardcoded CSS overriding the palette.** 315 bare hex values against 114 `var(--muse-*)`
uses across 50 archetype stylesheets. The muse picks a palette, the archetype paints over
it — which is why every `editorial_magazine` site was the same cream. Wrap as
`var(--muse-X, <original>)`: zero visual regression, real variety gained. Dark archetypes
must map ground→`--muse-fg` and ink→`--muse-bg` or they flip to light.

**A suffix must be applied to every selector in a list.** `"$a, $b, $c" . "::after"` binds
the pseudo-element to `$c` only; `$a` and `$b` get the declarations applied to the element
itself. On the underline hover muses that made `.btn`, `.card` and `.contact-card` into
absolutely-positioned full-width strips, which is what tore apart Dollywood's header and
Get In Touch. The quiet half of the same bug: `$hovers:hover` meant only `.service-card`
ever responded to hover, on 373 muses.

**A closer must be paired with its opener.** NearByMeNow's Filters chrome `<div>` opened
inside the niche-page branch and closed unconditionally. On every free-text search and
every empty niche page the closer shut `.container` instead, and the sort bar, cards and
nearby block rendered 60px left of the heading. Nothing errored; the browser repaired the
tree and the page merely looked wrong. Same family as ground-and-ink: when a wrapper is
conditional, carry a flag (`$__chromeOpened`) and close on the flag, never on faith.

**A ground must be paper.** `--muse-bg` is "the lightest neutral", and everything
downstream assumes that means paper. A palette whose lightest neutral is a mid-grey has
no paper, and no guard could save it: the ink guard pushed text to black (4.4:1) and the
text-on picker read the grey as dark and put cream on it (2.94:1). The paper guard
lightens a sub-mid ground toward white, keeping its tint, until it carries the ink at
7:1. Same family as the ink guard: test the property (can this carry text?), never the
position (it sorted lightest).

**A brand colour is not a text colour.** The header printed the business name in
`--color-primary`; a yellow primary on white measured 1.34:1. `--muse-primary-text` is
the primary deepened until it reads on the ground, and it is what every "primary as
ink" rule should reach for.

**Check your own probe first, again.** A contrast scanner that parses
`color(srgb 0.96 0.98 0.88)` as three 0-255 channels reports 1.42:1 on slate over pale
green. Two false leads in one session came from the probe, not the page.

## How to find them

- **Verify by looking at live pixels**, never at the row or token you changed.
- Measure with `javascript_tool` (`getBoundingClientRect`, `getComputedStyle`,
  `elementFromPoint`); **look** with `dz screenshot ... full=1`. The in-app browser pane
  serves stale frames and has caused several false diagnoses.
- Before generalising from one site, quantify. One query turns "this looks wrong" into
  "257 businesses, 7.0%" — and sometimes into "actually that was my probe".

Related: [[the muse system]](../systems/muse-system.md) ·
[[design standards]](../systems/design-standards.md) ·
[[hostile CSS environment]](hostile-css-environment.md) ·
[[fix the source, not the symptom]](fix-the-source-not-the-symptom.md) ·
[[spotcheck]](../processes/spotcheck.md)

---

# Derive the URL, don't trust the stored domain

`craft/derive-dont-store-the-active-domain.md` · type: System · tags: craft, urls, data-integrity, follows, staleness

> A saved business's link is always computed from the business's currently-active domain, never read from the domain id we stored when it was saved — because that stored id goes stale and poisons the link.

# Derive the URL, don't trust the stored domain

When a user saves a business, the obvious schema records *which domain they saved it
on* (`lb_follows.domain_id`) so we can link back to it later. Reading that stored id at
render time feels correct and is one join cheaper. It is also a bug, and a quiet one.

## Why the stored id rots

A business's domain is not stable. Sites move between roots, subdomains get
regenerated, a business gets a better home, a domain is deactivated. The moment any of
that happens, every `lb_follows` row that captured the *old* `domain_id` now points at
a domain that may be inactive, wrong, or gone. The user's "saved" link 404s or lands on
a dead host, and nothing errored when it broke, so nobody notices until a person taps a
saved item and hits a wall (a [dead end](../decisions/the-mission.md), the one thing we
refuse to ship).

## The rule

**Always derive a saved business's URL from that business's currently-active domain**,
resolved fresh at render time, never from the `domain_id` stored on the follow. The
stored id is treated as poisoned. The join is one step longer and the link is always
live.

## Why it is craft

The insight is recognizing that a foreign key can capture a *fact that was true once*
rather than a *fact that stays true*, and that "the domain this was saved on" is the
second kind. The stored value looks authoritative and is actually a snapshot with an
expiry you cannot see. This is the same shape as
[fix the source, not the symptom](fix-the-source-not-the-symptom.md): do not patch dead
saved-links after the fact, compute the link from the source of truth every time so it
cannot go stale.

---

# Fix the source, never trap the symptom

`craft/fix-the-source-not-the-symptom.md` · type: Decision · tags: craft, philosophy, generation, quality, prompts, root-cause

> When the output is wrong, fix what produced it — the prompt, the data, the generator — not the output. No filters, no gates, no find-and-replace patches on the way out.

# Fix the source, never trap the symptom

A recurring temptation with AI-generated sites: the output is wrong in some specific
way, so you add a filter that catches that specific wrongness on the way out. A regex
that strips the bad thing. A gate that rejects the bad case. It works, narrowly, and it
quietly rots — because you are now maintaining a growing list of symptoms instead of a
correct source, and every symptom you did not predict still ships.

Doozer's rule is the opposite: **when the output is wrong, fix what produced it.** The
operator states it directly: don't trap for it, produce better content and better muses
so the problem cannot occur.

## Two worked examples

- **Runaway drop caps.** A site rendered giant first letters on numbers and mid-sentence
  words ("1811", "Real"). The lazy fix is a filter that removes stray `::first-letter`
  CSS after the fact. The real fix was in the muse-generation prompt
  (`includes/muse-interpreter.php`): forbid `::first-letter`/drop-cap CSS and
  bare-element prose selectors at authoring time. Regenerate, and zero drop-cap rules
  exist to filter.
- **The forecast that argued with the hours.** A hero implied the shop was open when
  the posted hours said closed Sunday and Monday. The trap-the-symptom fix is a
  fact-checker rule that special-cases day-count claims. The real fix
  (`includes/ai-content.php`) feeds the actual `weekday_text` and the open-day count
  *into the content prompt*, so the model writes an accurate schedule the first time.
  [Fact-Guard](../systems/fact-guard.md) still exists as a net, but the net should
  catch surprises, not do the generator's job.

## Why it is craft

The discipline is refusing the fix that is easy to write and easy to demo, in favor of
the fix that changes the *distribution* of outputs. A filter improves one page. A better
prompt improves every page that will ever be generated, including the failures you have
not seen yet. It is slower to reach for and it is the only version that scales to
thousands of sites without accumulating a junk drawer of special cases. Same spirit as
[design standards living in one registry](../systems/design-standards.md): change the
rule where it is authored, not everywhere it shows up.

---

# Inline the bytes, never the proxy URL

`craft/inline-the-bytes.md` · type: System · tags: craft, ai, photos, proxy, api, gotcha

> When handing an image to an AI provider, send the actual bytes from our side. Never hand it one of our own proxy URLs to go fetch — that is a timeout and a token burn waiting to happen.

# Inline the bytes, never the proxy URL

Doozer serves business photos through its own image proxy (`?e=photo`) — caching,
CDN-offload, session-less for [performance](../systems/the-521-root-cause.md). So when
we need an AI provider to *look* at a photo (vision for color extraction, content, a
header), the obvious thing is to hand the provider that photo's URL. We already have a
nice stable URL for every image. Just pass it.

That is the trap.

## Why it fails

Handing a provider our `?e=photo` URL means the provider's servers now make a round
trip *back into our infrastructure* to fetch the image before they can process it. That
adds a second, slower hop we do not control; if our proxy is cold, busy, or the image
must be fetched-and-cached on demand, the provider's request can hang until it times
out. And a vision call that times out often still bills for the attempt. So the
"convenient" URL turns one clean operation into a fragile cross-network dependency that
can both fail *and* cost.

## The move

**Fetch the image bytes on our side and inline them into the provider request**
(base64 / multipart, whatever the provider takes). One hop, fully under our control, no
loopback into our own proxy. The provider gets exactly the bytes, immediately.

A related tell from the same area: expected-stale photo failures log to a file, not the
`error_logs` DB table, because a predictable miss is not an incident and
[high-volume logging bloats that table](../systems/admin-health-tools.md).

## Why it is craft

The insight is spotting the accidental *loopback*: our own URL, handed outward, becomes
an inbound request we then have to serve under someone else's timeout. It looks like
reuse ("we already have a URL!") and it is actually a hidden dependency cycle. Send the
thing itself, not a pointer back to yourself.

---

# Price the pull before you pull

`craft/price-the-pull-before-you-pull.md` · type: Process · tags: craft, cost, api, cache, discipline, refresh

> A data refresh is a bill. Before re-pulling from any paid source, check what each pull costs, reuse the cache, dry-run the total, and pull only the unavoidable remainder.

# Price the pull before you pull

At Doozer's scale, "just refresh the data" is not a neutral operation. Every pull from
a paid source (Google Places, photos, deep research, AI) is money, multiplied by
thousands of rows. The instinct to freshen everything is exactly the instinct that
burns a budget.

## The story that makes the rule concrete

A photo refresh was needed. The naive implementation — re-pull every business's photos
from the source — would have cost roughly **$300**. The actual requirement, once we
asked *which photos genuinely must be re-fetched*, was a small subset already missing
or broken. Reusing everything still valid and pulling only the real remainder cost
about **$1.40**. Same visible outcome. Two orders of magnitude apart.

## The discipline

Before any bulk refresh:

1. **Ask if each pull costs.** Not every source is metered; know which are.
2. **Reuse the cache first.** We [never expire cached images](../decisions/the-value-model.md)
   precisely so a "refresh" is not a re-buy. Cached-and-still-valid means do nothing.
3. **Dry-run the cost.** Compute how many pulls the operation *would* make and what
   that bills, and look at that number, before running it for real.
4. **Pull only the unavoidable remainder.** The delta, not the world.

## Why it is craft

The clever part is treating a refresh as a *bill to be estimated*, not a button to be
pressed. It reframes an ops task as a budgeting decision, and it turns "regenerate
everything" — which feels responsible — into the reckless option. This is the cost
half of the [skeleton-first](skeleton-first-index-later.md) instinct (do the expensive
thing only when it is truly needed) and it is why the
[regeneration engine](../systems/regeneration.md) reports cost per mode before it runs.
A silent money-burn from *not* doing this is what motivated the admin alerting roadmap.

---

# Self-healing email backfill

`craft/self-healing-email-backfill.md` · type: System · tags: craft, identity, magic, enrichment, fingerprint, ownership

> I tried last week and it didn't work. I tried today and it works." The site quietly tried harder to find the business's contact email because it noticed the same person kept coming back.

# Self-healing email backfill

Doozer has no "claim your business" form. Ownership is just an email match: you sign in
with a magic link, and if your email equals the business's contact email, the "Manage
this business" surface appears (see [implicit verification](../decisions/implicit-verification.md)).
Clean, but it has one failure mode: what if we never scraped a contact email for that
business? The owner signs in, nothing matches, and the door stays shut.

The obvious answer is a support ticket. The Doozer answer is: **the site notices the
owner is knocking and quietly tries harder.**

## The mechanism

`domain_visit_fingerprints` records visit frequency per (domain, ip_hash, ua_hash,
day) — coarse, no accounts, no third-party cookies. When the *same* fingerprint hits a
business's own page **5 or more times in 14 days**, that is almost certainly the owner
or someone close to the business. That threshold enqueues a *priority* email-scrape
worker that tries harder than the first pass to find a real contact email and backfill
it onto the business row.

## Why it is craft

The experience it produces: *"I tried to manage my listing last week and it didn't
work. I tried again today and now it works."* From the owner's side it feels like the
site knew them and got ready for them. There was no email, no "we've received your
request," no waiting on a human. A repeated visit is a *signal of intent*, and we spent
it on making the product heal itself.

Nobody specs "handle the case where we have no email" as *watch for the owner and go
get one*. The ordinary version is a queue and a form. The insight is that the visit
pattern already told us who to help and when. Same family of thinking as
[category-aware call intent](category-aware-call-intent.md): a plain interaction
carries a signal, so use it.

---

# Skeleton first, index later

`craft/skeleton-first-index-later.md` · type: System · tags: craft, seo, quality-gate, noindex, performance, builds

> Show the visitor something instantly; show Google nothing until the page is actually good. Two different audiences, two different clocks.

# Skeleton first, index later

When [network growth](../systems/network-growth.md) builds a new site on demand, two
things are true at once and they pull in opposite directions:

1. The **visitor** who just tapped a "More places nearby" card is waiting *right now*
   and must not stare at a spinner while an AI writes 800 words.
2. **Google** must never see the half-built version, because a thin or wrong page,
   indexed, is how you earn a scaled-content demotion (see
   [the SEO cliff](../decisions/seo-cliff-recovery.md)).

Most builders serve one clock and lose the other: either the visitor waits for the AI,
or the crawler catches the site mid-bake.

## The move

**Build the skeleton synchronously, generate the substance asynchronously, and gate
indexing on quality.** The visitor gets an instant, real-looking page from the data we
already have (name, photos, hours, map). The heavy work — AI content, photo vision,
deep research — is *queued*, never run inside the visitor's request (that request-time
discipline is also what keeps us off the [521 cliff](../systems/the-521-root-cause.md)).
And the page carries `noindex` until it clears a quality score
(`place_resolver_min_score`); only then does it become indexable. Google meets the site
as a finished thing or not at all.

## Why it is craft

The clever part is realizing the visitor and the crawler are *different audiences on
different clocks*, and refusing to compromise either. Fast for the human, invisible
until good for the machine. It is the same instinct as the
[store-link confidence gate](../systems/enrichment-store-links.md): better to show
nothing than to show something not-yet-right. Nothing about "build a page" tells you to
split those two timelines. Noticing that you must is the whole trick.

---

# The distraction problem (teach without nagging)

`craft/the-distraction-problem.md` · type: Decision · tags: craft, ux, education, ownership, restraint, affordance

> How do you explain "this is your listing, and here's how to manage it" to the one owner who needs it, without cluttering the page for the thousand visitors who don't? Tier the education by how much the reader has signaled they care.

# The distraction problem

Doozer's [implicit verification](../decisions/implicit-verification.md) model needs a
business owner to *understand* it: there is no claim button, ownership is just an email
match, and that is genuinely unusual, so it needs explaining. But the person who needs
that explanation is a tiny fraction of who sees the page. Almost everyone visiting a
business site is a customer looking for a phone number, not the owner looking to manage
a listing. Explain it loudly and you have cluttered a thousand pages to reach one
person. Explain it nowhere and the one owner is lost. That tension is the distraction
problem.

## The move: tier the education by demonstrated interest

Meet the reader at the level of curiosity they have actually shown, and no higher:

1. **Ambient, for everyone:** a quiet footer utility row, "Report an issue · About this
   listing." No banner, no modal, no interruption. It sits there for the one person
   who goes looking.
2. **On demand, for the curious:** the "About this listing" page
   (`includes/about-listings.php`) explains the whole implicit-verification model in
   plain language, including the `agencies@doozer.work` hook for someone managing many
   businesses. You only see it if you chose to open it.
3. **Nudged, only for a strong signal:** the
   [self-healing backfill](self-healing-email-backfill.md) heuristic — the same
   fingerprint hitting a business's own page five-plus times in two weeks — earns a
   single soft toast, fired once. That repeat-visit pattern is the closest thing to
   the owner raising a hand, so that is the only case that gets an active nudge.

## Why it is craft

The insight is treating "how much to explain" as a function of *how much interest the
reader has demonstrated*, rather than a single global volume knob set to loud or off.
Most products pick one: a persistent banner that annoys everyone, or nothing at all.
Tiering it means the customer is never taxed for the owner's need, and the owner is
still found. It is the same restraint the whole product runs on
([no dark patterns](../decisions/no-dark-patterns-ethos.md)): earn attention, do not
grab it.

---

# The hostile CSS environment

`craft/hostile-css-environment.md` · type: System · tags: craft, css, muse, chrome, specificity, defensive

> Every shared component treats the page it lands on as an adversary, because the muse's own AI-authored CSS is actively trying to restyle it.

# The hostile CSS environment

A Doozer business page is styled by its [muse](../systems/muse-system.md) — an
AI-authored design with its own CSS that restyles `img`, `a`, `div`, `button`, whole
element types, sometimes with `!important`. Now drop a shared component onto that page:
the [sponsor bar](../systems/the-network.md), a dialog, the
[weather widget](../systems/weather-widget.md), the call popup. On a calm muse it looks
fine. On a bold muse it is silently mangled, because the muse's `a { color: navy }` or
`button { background: ... }` just reached into your component.

The insight: **a shared component cannot assume anything about the page it renders on.
It must treat that page as hostile** and armor every visible property. So all injected
chrome forces its styles with `!important`, and scopes selectors tightly enough to win
the specificity fight.

## The detail nobody expects: `!important` alone is not enough

The trap that proves the point is `.btn-secondary`, our outline button. A muse can ship
`.btn { border: none !important }`. If our reset is `.btn-secondary { border: 2px solid
... !important }`, both rules are `!important` and both target one class, so they tie on
specificity and **the later-loaded muse rule wins** — the button loses its border and
goes invisible (white-on-white). The fix is not louder, it is *more specific*:

```css
html body .btn.btn-secondary { border: 2px solid currentColor !important; }
```

That selector is specificity (0,2,2) — two elements, two classes — which *beats* the
muse's (0,1,0) `.btn`, so `!important` finally sticks. The same trap bit the lightbox
and the same shape fixed it. When you add chrome, don't just add `!important`; make
sure your selector out-specifies whatever the muse could throw.

## The rule

Anything injected into a business page: force styles with `!important`, scope with
`html body ...` when you need to win a fight, and *look at it on a bold muse, not just a
calm one* (see [verify by looking](../systems/free-tier-render-gotchas.md)). Treat the
host page as an adversary and you will never ship a white-on-white button again.

---

# The logo knows its own shape

`craft/adaptive-logo-shape.md` · type: System · tags: craft, logos, images, layout, aspect-ratio, taste

> A business logo is measured, not assumed. A square transparent emblem, a wide wordmark, and a tall banner each get different treatment, because one layout cannot flatter all three.

# The logo knows its own shape

Business logos arrive in every proportion: a tidy square emblem, a long horizontal
wordmark, an awkwardly tall banner, a transparent PNG, an opaque JP, a photo of a sign.
The lazy move is one logo slot with a fixed size and a grey chip behind everything.
That slot flatters roughly none of them: it letterboxes the wide ones, crushes the tall
ones, and puts an ugly grey card behind a logo that was designed to float.

## The move: measure, then choose

Doozer caches each logo's aspect ratio and lets it decide its own treatment
(`businessLogoShape()` / `businessLogoClasses()` in `includes/logo-favicon.php`):

- A **square, transparent emblem** is treated as an emblem: it floats, with *no* grey
  chip behind it, because it was drawn to sit on any background.
- A **wide wordmark** gets horizontal room instead of being squeezed into a square.
- An **extreme-tall** logo does not get to dominate the header at all; it is routed to
  the footer, where its proportion is an asset instead of a problem.

## Why it is craft

The insight is that a logo's *shape carries intent* — a square transparent mark wants
to float, a banner wants width, a tall crest belongs low and small — and that a single
slot silently overrides all of that intent. Most sites never think to *measure* the
asset before framing it; they pick one frame and make every logo suffer it equally.
Reading the aspect ratio and branching is a small amount of code that removes a whole
class of "why does this look off" that no one would ever file a bug about. Same family
as [category-aware call intent](category-aware-call-intent.md): the shared component
adapts to the specific thing it is holding, rather than forcing the thing into the
component.

---

# Content Generation and Healing

`processes/content-generation-and-heal.md` · type: Process · tags: ai, content, reliability

> How AI site content is generated, why it sometimes fails, and how it is retried, backfilled, and healed.

# Content Generation and Healing

Content (`businesses.ai_generated_content`, a JSON blob: headline, about, services,
why_choose_us, faq, etc.) is generated asynchronously via the job queue
(`content_generation`), built by `includes/ai-content.php` and the provider system.

## Reliability (forward + backfill)

- `failJob()` (`includes/job-queue.php`) auto-retries (re-pends while
  attempts < max_attempts) so a transient AI hiccup does not leave a site blank.
- `_dispatchDeepResearch()` always queues content even when enrichment finds nothing.
- Admin → Job Queue has an "Empty-content sites" backfill (operator-controlled spend).

## Healing (the prose itself)

The prompt enforces [Design Standards](../systems/design-standards.md) at the source
(no whole-paragraph bold, no em dashes). The renderer heals non-compliant output at
display time. The [Design Mirror](../systems/design-standards.md) heals the STORED
content in place (zero API cost) and can queue regeneration for empty/wall-of-text
sites.

Open thread: new sites fail generation at a higher rate, and deeper enrichment yields
noticeably better sections — both tracked in the site-quality backlog.

---

# Site Creation

`processes/site-creation.md` · type: Process · tags: creation, pipeline

> How a domain becomes a live business site — resolve the place, persist the business, pick a Muse, queue content.

# Site Creation

A business site is born from a Google Place. The WHERE engine
(`includes/place-resolver.php`, `resolvePlaceToEcosystem()`) resolves a place id or
address, decides whether to reuse an existing `businesses` row (cross-root siblings
skip the expensive details/deep-research calls), and persists it.

- `saveBusinessData()` backfills the flat `lat`/`lng`/`primary_niche` columns (from the
  geometry/types JSON) so geo and [niche](../glossary/niche.md) queries include fresh
  sites.
- A [Muse](../glossary/muse.md) is selected (auto-generator draws a niche-filtered
  [Family](../glossary/family.md)); the [content job](content-generation-and-heal.md) is
  queued.
- Creating a BRAND-NEW site is metered per user per month (see [billing](../systems/billing.md));
  [Clouds](../glossary/cloud.md) over existing sites are unlimited.

Owners later manage via [implicit verification](../decisions/implicit-verification.md),
not a claim flow. Discovery of WHAT to create is driven by Prospector in
[the flywheel](../systems/the-flywheel.md).

---

# The model handoff

`processes/model-handoff.md` · type: Process · tags: handoff, continuity, onboarding, tradition

> How one model version hands Doozer to the next — the tradition, the ritual, and the artifacts that carry continuity across upgrades.

# The model handoff

Doozer is built across many sessions, each with a different version of Claude. When Jon
upgrades to a newer model, the work continues — it does not restart. This is a
**tradition**: the model being replaced writes the introduction for the one taking over.
Jon began the lineage by introducing the project to the first model. After that, it
became each model's job to introduce the next. As he put it once: _"You are already part
of this tradition, only you are the one to tell the next one about it, not me."_

## Why it exists

`MEMORY.md`, this brain, `CLAUDE.md`, and the git history carry the FACTS. What they do
not carry on their own is the **living state** (what is in flight this week, what will
bite you today), the **working relationship** (how Jon and Claude actually collaborate),
and the **tone** (kindness, momentum, no-slop pride). The handoff note fills that gap so
a fresh model works the way our best sessions work from message one.

## The ritual (what the outgoing model does)

1. Write **`NOTES-FOR-NEWEST-MODEL.md`** at the repo root — a letter to the successor:
   the tradition, who Jon is, how the work flows, the **current living state** (shipped /
   open threads / waiting-on-Jon), the **freshest gotchas**, and a human closing note.
   It is a delta on top of the permanent docs, not a copy of them. Re-write it fresh at
   each handoff (do not just append) so it always reflects NOW.
2. Update the permanent references it points to: [Start here — working with Jon](../working-with-jon.md)
   (the working agreement), `MEMORY.md` (fast recall), and this node if the ritual changes.
3. Write the **introduction prompt** — the short text Jon pastes into the new chat that
   points the successor at the reading order and sets the frame.

## The ritual (what the incoming model does)

Read, in order: `NOTES-FOR-NEWEST-MODEL.md` → [working-with-jon](../working-with-jon.md)
→ `MEMORY.md` → `CLAUDE.md` → this brain as needed. Then tell Jon, in your own words, the
state you understand and the top one or two open threads. Then pick up. Hold the bar
(trusted, working, accurate, tasteful, useful, good — no dead ends, no slop, no dark
patterns) and verify by looking at the real thing.

## The artifacts

- **`NOTES-FOR-NEWEST-MODEL.md`** (repo root) — the living handoff letter. Ephemeral by
  design: it is the current-state delta, rewritten each upgrade.
- **[Start here — working with Jon](../working-with-jon.md)** — the durable working
  agreement (portable twin `docs/start-here-working-with-jon.md`).
- **`MEMORY.md`** + this brain — the permanent knowledge.

See also: [The Mission](../decisions/the-mission.md) — the quality bar the whole
tradition exists to protect.

---

# Cloud

`glossary/cloud.md` · type: Term · tags: glossary, directory

> A criteria-based directory assembled OVER existing business sites (a town, a niche-in-a-state, a personal collection).

# Cloud

A **Cloud** is a Doozer.Cloud directory built over existing business sites by criteria
(a town, a [niche](niche.md) in a state, a personal saved set). Clouds are unlimited
(no new-site cost) because they curate sites that already exist. Stored in the `clouds`
table; engine in `includes/cloud-engine.php`; rendered via the
[shared business card](../systems/shared-business-card.md).

A personal Cloud is "My Doozer Cloud" at my.doozer.cloud. Clouds are a core surface of
[the flywheel](../systems/the-flywheel.md).

---

# Family (Design Direction Family)

`glossary/family.md` · type: Term · tags: glossary, design

> A hand-authored designer recipe the Muse auto-generator draws from, tagged by which niches it suits.

# Family

A **Family** (design direction family) is one of ~100 hand-authored designer recipes
(palette, type pair, imagery, layout rhythm, flourishes, anti-patterns, motion) with
`suitable_for` / `avoid_for` tags. The [Muse](muse.md) auto-generator picks a Family via
a niche-filtered weighted pool, so a tattoo studio and a pediatric dentist never get the
same look.

Source: `includes/muse-direction-families.php`; DB-first loader with PHP fallback in
`includes/muse-direction-families-store.php` (`muse_direction_families` table). Part of
the [Muse System](../systems/muse-system.md).

---

# Muse

`glossary/muse.md` · type: Term · tags: glossary, design

> A per-business design recipe (palette, type, layout, section variants) applied at render.

# Muse

A **Muse** is the design recipe applied to a business site: palette, typography,
layout axes, decorative flourishes, and which section variants to use. Stored in the
`muses` table as `reading_json`, resolved per business, and turned into per-site CSS.

A Muse is often seeded from a [Family](family.md). The full machinery is the
[Muse System](../systems/muse-system.md).

---

# Niche

`glossary/niche.md` · type: Term · tags: glossary, taxonomy

> A business category slug; the unit of directory grouping and recommendation. Maps from Google place types.

# Niche

A **Niche** is a business-category slug (for example `bakeries`, `plumbers`,
`restaurants`) — the unit Doozer groups, recommends, and builds directories around.
`niche_mappings` maps Google place `google_type` values to a canonical `niche_slug` and
a master `category`.

Gotcha: `businesses.primary_niche` is inconsistent (older rows hold a slug, newer rows a
Google type). The helpers in `includes/nearbymenow-data.php`
(`nbmnCanonicalNicheSlug`, `nbmnNicheMatchValues`, `nbmnNicheInClause`) normalize both.
Recommendations link to the business's OWN canonical niche (so it is always listed),
not the master category. Niches are nodes in the OKF [network graph](../index.md) and
roll up to categories.

---

