The Doozer Brainlive from the knowledge bundle · v2.2.618 · 2026-09-13 17:14 ETindex · markdown

The Doozer Brain — public edition

Generated live from the running platform at 2026-09-13 21:14 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 (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


The Doozer Brain

index.md

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

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 for the tradition.

New session? Read Start here — working with Jon 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 — why Doozer exists + the non-negotiable
  2. quality bar (trusted / working / accurate / tasteful / useful / good).

  3. The Doozer Story — the origin and the idea.
  4. The Architecture — one index.php serving unlimited
  5. sites; what a Doozer site actually IS.

  6. The Network — every product in the ecosystem and how
  7. they interconnect (the breadth).

  8. The Value Model — how value is created and
  9. captured (the economics).

  10. The Flywheel — how it all compounds.
  11. Doozer vs Doozer+ — the free/paid direction.
  12. The Observed World — render-on-demand: every
  13. local business exists once observed, and each materialized site is both inventory and a pre-built lead (the self-funding flywheel).

Organization

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. The whole bundle exports as one portable .zip (Admin → Doozer Brain → Export). No secrets live in the brain by design.


Adopt Open Knowledge Format for the Doozer brain

decisions/okf-adoption.md

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 bundle (this knowledge/ directory) and build exploration tools on it.

Why

Scope and caveats

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.

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,
  4. IN_NICHE, NEARBY, COVERS_NICHE/PLACE, HOSTS_EVENT). Density is the long-term moat, but only emit edges DERIVED from real data, never invented.

  5. Atlas exists automatically for every Doozer host (business, cloud, root, VD).
  6. Every field must justify its existence: "will this help a human, AI, or another
  7. Doozer system understand the world around this entity?" If no, do not emit it. Keep Atlas small and intentional, not a dumping ground.

  8. Provenance is the differentiator: per-field source + an owner-verified tier (tied to
  9. implicit verification) 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

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 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 drawn from the shared family library, assembled from the standard section framework. 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. 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

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

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: 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 and Design Standards: the product should never look cheap or machine-stamped.


Implicit Verification (no claim system)

decisions/implicit-verification.md

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.


// 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);

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 ethos and underpins owner-facing parts of the flywheel.


Magellan — cross-service business identity mapping

decisions/magellan-id-mapping.md

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 have a Ticketmaster venue ID. The reliable join is the ID. Magellan makes that the only join.

What Magellan does

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

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:

Sibling rules with the same spirit: the icon taste rule, 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

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 value model, SEO cliff, and the mission.

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,
  2. farmers markets, churches, shopping, services. Any niche, any grain.

  3. Area — a town, a county, a region.
  4. Non-obvious connection — the businesses you need together for a real
  5. 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:

Real estate is also the wedge for monetization: realtors are heavy direct- mail buyers, so they are prime Thanks.io claim / Doozer+ targets now (record_types onlybusinesses in a farm area, mail them their site). When AwesomeAddresses (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)

The guardrails that decide success vs. cliff (do NOT skip)

  1. Quality-gated indexing. The SEO cliff proved that
  2. 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.

  3. No slop, taste first (the mission) — a cloud with three
  4. junk members is worse than no cloud. Curation and a membership quality bar.

  5. Cost discipline ([[feedback_api_costs]]) — painting-on-demand and radius
  6. buys spend money; gate behind observation/demand, reuse cache, never mass-build speculatively.

  7. Ethics ([[project_doozer_ethos]]) — organic value, no dark patterns; mail
  8. 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)

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

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

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:

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

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 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 — exists the moment it is observed. It is, in a way, already there.

The engine for this already exists: resolvePlaceToEcosystem (place-resolver) 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
  2. substrate AI agents can query).

  3. A pre-built lead — a finished, live product sitting on a real business's
  4. 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 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:

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

Build order (cheapest-first)

  1. Step 0 — claim CTA in lead emails. SHIPPED v2.2.355. nbmnLeadEmailBusiness()
  2. appends a "claim your listing" block (their live site + the email-is-the-key pitch); the claim landing 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.

  3. Step 1 — QR + claim/upgrade landing on top of existing Doozer+ / Stripe.
  4. Step 2 — Thanks.io direct mail, gated on the reality meter (only mail a
  5. site worth pride).

  6. Alongside — the Demand Ledger, the brain that says what to materialize and
  7. 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 — the concrete observe→serve→convert→learn machine (postcards, offers, Hot-Leads CRM). Related: the value model, two-tier model, SEO cliff, no dark patterns.


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

decisions/seo-cliff-recovery.md

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:

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

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.

Value created (before any charge)

Value captured (how money is made)

  1. Lead generation — the primary engine. Every call, message, and intent
  2. signal is captured and routed to the business through LeadBuilder. Qualified leads are the product a business will pay for because they map directly to revenue.

  3. Ownership / Doozer+ — a business claims its site
  4. for free and can upgrade to the paid Doozer+ tier: a bespoke full site, priced via Stripe billing.

  5. Premium features — analytics, custom branding, agency multi-site
  6. management, higher new-site caps.

  7. Sponsorship / advertising — tasteful, network-level placements
  8. (renderDoozerSponsorBar()), never spammy.

  9. Affiliate / referral — booking, ticketing, and commerce CTAs where they
  10. genuinely help the visitor (e.g. tickets on live music, vetted "Shop Now" links).

Why this compounds

Each captured signal makes the next site cheaper to justify and better targeted (via Prospector and enrichment). Value capture funds value creation, which produces more signal. That is the flywheel; 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

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

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:

What v2.2.420 added

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

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

Roadmap (the Marketing Engine this seeds)


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

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

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 (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
  2. + deep source-of-truth). Record the baseline to beat.

  3. An escalated niche → compare its fresh average to the baseline:
  4. 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.

  5. 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()):

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

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

Still to wire (follow-ups)

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

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.

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

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 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.
  3. This is a deliberate manual choice (a "No header image" checkbox in the cloud editor), never the default.

  4. 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 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). 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.phpcloudTextureLayer() (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:

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

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

Clouds

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

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.

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


Content Generation Pipeline (providers, models, timeouts)

systems/content-generation.md

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. 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: 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
  2. includes/ai-provider-system.php. Reads the per-content-type model config, builds the prompt from the DB template, calls callAnthropic() / callOpenAICompatible().

  3. Legacy path (fallback): ai-content.php's own callAnthropic/callOpenAI. Has its own
  4. 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 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 for measuring boilerplate.

Failure modes (decode these fast)

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 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, not raw php -r pasted into a root shell.

Diagnostics

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


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

systems/context-city-poisoning.md

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:

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:

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

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
  2. designStandardsPromptRules() so new content is born compliant (never bold a whole paragraph; only short phrases; no em dashes; 2 to 3 paragraphs).

  3. Renderincludes/prose-render.php delegates to designStandardsTameInline()
  4. and designStandardsStripEmDash() as a safety net for non-compliant model output.

  5. Diagnose — the Design 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 process and the no dark patterns ethos.

The regeneration readability gate

The unified regeneration engine (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'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

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 — roots, Clouds, NearByMeNow — 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

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 and Muse system.


Doozer Atlas (outward AI emission)

systems/atlas.md

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 — 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 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). Grow the Brain and the Atlas gets richer context to project.


Doozer Digest (AI daily newsletter)

systems/doozer-digest.md

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 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.phpdigestBusinessStats() 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 — the same fleet supervised through a morning email instead of a dashboard sweep. It shares the signal miners with Marketing (changelog, LeadBuilder metrics, recent discoveries), so the numbers are the ones that actually run the business.

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


Doozer Town and the Board Game view

systems/doozer-town.md

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

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

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

The three alert signals (📬 email_open ping)

  1. Old email opened (>= 14 days after send) — the headline: a dormant thread
  2. reawakening means something is up. Alert-level, 6h re-throttle per send.

  3. Unusual attention — >= 4 human opens in 20 min. Hourly re-throttle.
  4. High-signal first open — first human open of an important kind
  5. (lead_forward, claim, owner_digest, lead).

All opens are stored regardless, so the Digest 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

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 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). 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, the mission). 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 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

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.

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

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.

What it does

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

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) and at the DB boundary (hooked into saveGeneratedContent()), so both fresh builds and re-saves are guarded.

Why it exists

The Elks fleet 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: 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. 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

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:

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:

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

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:

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

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 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 and the Events view in the directory framework. 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) already derive a coarse home centroid for signed-in users to enable proximity features.

Why it matters

Per the GIGO principle, 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 business site, the Clouds, the vertical directories, and the Atlas 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 reaching into the physical world.


Hot Leads — the internal Doozer-sales CRM

systems/doozer-prospects.md

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


IndexNow (instant crawl notification)

systems/indexnow.md

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

Triggers (both async, via the indexnow_submit job)

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

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). There is ONE lead pipeline; every surface feeds it.

The pipeline

nbmn_lead_requests is the single table. A lead enters from:

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 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: the write "succeeded," the row just never arrived.


LeadBuilder (the lead-intelligence platform)

systems/leadbuilder.md

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

What flows in

Every demand signal on the network bridges to LeadBuilder:

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 sells, so the same identity spans the free site, the lead intelligence, and the paid tier. It is the revenue engine of the flywheel, 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'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 daysinvalid_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

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 (one host row routed by includes/vd-router.phpincludes/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.

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 and the Cloud Factory build next — the flywheel applied to events.

Ecosystem ties

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.


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

systems/magellan-and-feeds.md

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

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

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). Each request drafts 3 variants; the operator picks one.

Auto-draft loop (M4)

When a Prospector 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.

Marketing is the outward voice of the flywheel; the outward data voice is the Atlas.


Money Clouds (affiliate vertical directories)

systems/money-clouds.md

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

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 and monetization, beyond leads and premium. Sequence after the core infra settles; see [[project_search_to_site_money_clouds]] and the events waterfall.


NearByMeNow (the traffic + directory layer)

systems/nearbymenow.md

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

What it does

Phase 2/3 features (shipped)

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, no two-click detour), and converts browsing into leads. It is the traffic engine of the flywheel.


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

systems/network-growth.md

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

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 =
  2. {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.

  3. Expansion target — WHERE a build lands: expand_target = self (a subdomain
  4. 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, refined so growth is local, intent-driven, and never pollutes a branded network. See [[project_search_to_site_money_clouds]] and NearByMeNow.


Niche matching and live discovery

systems/niche-matching-and-discovery.md

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.
  2. Google stamps them on 3,393 of 3,635 businesses, so including them makes every niche universal.

  3. Exact primary_niche matches rank FIRST ($nichePrecision in
  4. 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)

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

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: 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. Yet their heroes diverge completely, because their reviews and place data diverge (surveyed July 2026):

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:

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
  3. Magellan ID mapping spine), and

  4. every other surface that reads the pipeline: vertical directories
  5. (e.g. livemusictonight.com), clouds, the shared business card, and the outward Atlas emission.

So enriching one place is never local. It compounds across the network. This is the enrichment half of the flywheel: 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

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

What it powers

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.


Postcard targeting (the Postcard Queue)

systems/postcard-targeting.md

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:

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 so a scanned card lands on an accurate, better page.

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


Postcards

systems/postcards.md

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 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 (machine-readable) and the distilled Doozer Story.


Promo Codes + the one-time claim offer

systems/promo-and-offers.md

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.phpshowClaimOffer) shows Doozer+ 50% off for LIFE, $49.50/yr forever with exactly two buttons:

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, claim codes, implicit verification, [[feedback_right_offer_right_audience]].


Prospector (discovery + demand-driven growth)

systems/prospector.md

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

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 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 or vertical the market is asking for. Discovery → build → auto-draft marketing post closes the loop: a new site can announce itself.

Prospector is the intake valve of the flywheel.


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

systems/regeneration.md

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, 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 / family fix?"

The unified regeneration engine

includes/regeneration.phpregenerationRun($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 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 that calls the unified engine at scale (e.g. converting the elks.club sites in one pass). Supported by the Schema Audit 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 keep turning: the fleet gets better as the pipeline does, on purpose, without downtime.


Shortlinks + QR routing (doozer.link)

systems/shortlinks.md

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

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

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.linkshortlinkHandleRequest from index.php). Table: shortlinks (self-heals).

The permanent, claim-aware site link

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

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, the flywheel, postcards.


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

systems/source-of-truth-cache.md

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: 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 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,
  2. menu + prices, products, team, service area, booking URL). Accuracy-first: null when the source is silent, NEVER invent.

  3. Validation filter — cross-check the generated Doozer site vs. the facts; the source
  4. wins on conflicts; flag low-confidence for review.

  5. Never copy prose, extract facts — accuracy + no duplicate-content SEO hit (the
  6. SEO cliff was scaled-content demotion).

  7. Deep extractions (full menus, product catalogs) are a Doozer+ feature.

Pairs with postcard targeting: 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, fact guard.


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

systems/thanksio.md

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)
  2. mints a claim code + doozer.link QR (claim-codes) 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).

  1. Radius targeting → new site inventory (reverse-engineering).
  2. 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.

  1. Webhooks back into intelligence. ?e=thanksio_webhook (gated by a shared
  2. 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

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

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, shortlinks + QR, AI Postcards (campaigns + Factory + Theme Studio), the observed world (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

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
  2. REFUSES to register a service worker with the wrong MIME type. Serve PWA files before the router, with explicit Content-Type.

  3. Cache-first on an app shell serves a stale page and reads exactly like a failed
  4. deploy. Use network-first with cache as the offline fallback for anything still changing.

  5. **A component honouring prefers-color-scheme on a host page with a hardcoded light
  6. body** gives dark rows on white. The shell must OWN html, body, tokens on :root.

  7. The host styles header at TAG level, so any <header> inside a component
  8. inherits it. Same family as the business-site !important chrome rule.

  9. A script that snapshots elements at parse time misses markup below it. The chrome
  10. 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

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:
  1. The domains row for the host resolves the businesses row (the real data).
  2. business-site.php composes the page: muse CSS/type, the AI
  3. 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 and deploy with backup + rollback.

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 over the same inputs. The site never goes dark, and it gets better as the pipeline improves. This is the premise behind the flywheel and the whole data-enrichment direction.


The Chamber Chamber

systems/chamber-chamber.md

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)

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

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


The Doozer Brand System (ecosystem identity kit)

systems/brand-system.md

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

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)

Ready-to-paste tokens


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

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

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)

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

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

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:

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 (no claim system). The graph of who-is-where-in-what is exactly what the OKF network nodes expose.


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

systems/the-doozer-score.md

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.

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:

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

  3. The rate is an assumption and is always stated wherever the score is shown.
  4. Change doozer_score_site_value, change the number.

  5. STICK is marginal production cost only — AI plus Google APIs. It excludes hosting
  6. 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

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 a bespoke identity in code, layered over the per-site Muse 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:

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+ direction proven on a real fleet. The build taught durable lessons folded into render gotchas 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 — the theme makes a lodge look dignified; real enrichment makes it sound like itself.


The Growth Engine — observe → serve → convert → learn

systems/growth-engine.md

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:

4. CONVERT (the offer). The right offer to the right audience at every touch, from ONE resolver — never mis-targeted:

5. MEASURE + LEARN. Every touch is tracked and fed back:

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):

Guardrails (non-negotiable)

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

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

The Muse System

A Muse 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

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
  3. through museSanitizeCustomCss() first (no @import, no external url(), no </style injection).

  4. Contrast safety (includes/muse-contrast.php) — AA-safe prose colors against the
  5. effective backgrounds.

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

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):

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, not one-off CSS overrides.

Vocabulary: Muse, Family, Niche.


The Muse v2 layer, and the brief that feeds it

systems/the-muse-v2-layer.md

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:

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

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.

The surfaces

The loop (why it compounds)

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


The Niche Color System

systems/niche-color-system.md

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 (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 feel like one system instead of many pages. It is a design PRIMITIVE (like the design standards 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) 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

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 · Source of Truth Cache · The Observed World


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


$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',
  2. 302 straight to cdn_url. No local file needed. Gated by the platform setting image_serve_from_cdn (currently 1).

  3. Local disk cachecache/images/<cache_key> (path in image_cache_path).
  4. Negative cacheneg_<cache_key>. Stale Google references return HTTP 400
  5. 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.

  6. Fetch from Google — New API places.googleapis.com/v1/<name>/media, or Old API
  7. maps.googleapis.com/maps/api/place/photo.

  8. Determine the content type by SNIFFING MAGIC BYTES, not by trusting the upstream
  9. header (see gotchas).

  10. Serve the visitor, then persist. After fastcgi_finish_request() — so the
  11. 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

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:


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
  2. back to a plate while its photo sat on the CDN at 400.

  3. Never trust the upstream Content-Type. Read the last header across redirects
  4. 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.

  5. Correcting an object does not correct the cached response. The CDN edge had
  6. 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.

  7. Structured data must publish warm images only. ?e=photo buys on first request
  8. 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.

  9. A stamped row is not a fixed row. An early version of the healer stamped
  10. 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

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 may pick a variant; otherwise a classic fallback renders.

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

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

Rendered network-wide: NBMN, Cloud, 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 Weather Widget

systems/weather-widget.md

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

How it works

includes/weather.php is the whole engine:

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

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

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:

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

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):

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):

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

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

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 machirank / 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

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:

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 uses for color.

Where the signal goes

Taps are lead intent. They feed lead_intents and bridge into LeadBuilder, and the same chip vocabulary powers the business-site "Call Now" and the shared business card Call popup, so the experience is identical everywhere the business appears. See Lead Capture. Related craft: fix the source, not the symptom (these chips are authored data, not a scraped guess).


Cross-root identity without third-party cookies

craft/cross-root-identity.md

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.

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

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

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

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, 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: 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

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

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: change the rule where it is authored, not everywhere it shows up.


Inline the bytes, never the proxy URL

craft/inline-the-bytes.md

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

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

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
  3. precisely so a "refresh" is not a re-buy. Cached-and-still-valid means do nothing.

  4. Dry-run the cost. Compute how many pulls the operation would make and what
  5. that bills, and look at that number, before running it for real.

  6. 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 instinct (do the expensive thing only when it is truly needed) and it is why the regeneration engine 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

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). 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: a plain interaction carries a signal, so use it.


Skeleton first, index later

craft/skeleton-first-index-later.md

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 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
  2. and must not stare at a spinner while an AI writes 800 words.

  3. Google must never see the half-built version, because a thin or wrong page,
  4. indexed, is how you earn a scaled-content demotion (see the SEO cliff).

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

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 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
  2. listing." No banner, no modal, no interruption. It sits there for the one person who goes looking.

  3. On demand, for the curious: the "About this listing" page
  4. (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.

  5. Nudged, only for a strong signal: the
  6. self-healing backfill 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): earn attention, do not grab it.


The hostile CSS environment

craft/hostile-css-environment.md

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 — 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, a dialog, the weather widget, 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:


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

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):

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

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)

Healing (the prose itself)

The prompt enforces Design Standards at the source (no whole-paragraph bold, no em dashes). The renderer heals non-compliant output at display time. The Design Mirror 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

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.

Owners later manage via implicit verification, not a claim flow. Discovery of WHAT to create is driven by Prospector in the flywheel.


The model handoff

processes/model-handoff.md

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

  3. Update the permanent references it points to: Start here — working with Jon
  4. (the working agreement), MEMORY.md (fast recall), and this node if the ritual changes.

  5. Write the introduction prompt — the short text Jon pastes into the new chat that
  6. 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.mdworking-with-jonMEMORY.mdCLAUDE.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

See also: The Mission — the quality bar the whole tradition exists to protect.


Cloud

glossary/cloud.md

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

A personal Cloud is "My Doozer Cloud" at my.doozer.cloud. Clouds are a core surface of the flywheel.


Family (Design Direction Family)

glossary/family.md

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


Muse

glossary/muse.md

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. The full machinery is the Muse System.


Niche

glossary/niche.md

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 and roll up to categories.