{
    "name": "The Doozer Brain (public edition)",
    "description": "Doozer's complete working knowledge in Open Knowledge Format. Generated live; never a snapshot.",
    "format": "OKF v0.2",
    "owner": "BerrySmart",
    "generated": "2026-09-13T21:13:55+00:00",
    "node_count": 93,
    "held_back": 13,
    "note": "Omissions are operational security only (infrastructure failure modes, internal endpoint auth, claim-code mechanics). No strategy, economics, or product thinking has been removed.",
    "markdown_url": "https://doozer.work/brain",
    "nodes": [
        {
            "id": "index",
            "title": "The Doozer Brain",
            "type": "Index",
            "group": "root",
            "description": "Doozer's complete knowledge in Open Knowledge Format — the idea, direction, systems, processes, value, and vocabulary behind the entire platform.",
            "tags": [
                "okf",
                "brain",
                "index"
            ],
            "path": "index.md",
            "links": [
                "okf-adoption",
                "AGENTS",
                "model-handoff",
                "working-with-jon",
                "the-mission",
                "the-doozer-story",
                "architecture",
                "the-network",
                "the-value-model",
                "the-flywheel",
                "two-tier-model",
                "the-observed-world",
                "index",
                "category-aware-call-intent",
                "hostile-css-environment",
                "skeleton-first-index-later",
                "self-healing-email-backfill",
                "price-the-pull-before-you-pull",
                "fix-the-source-not-the-symptom",
                "inline-the-bytes",
                "cross-root-identity",
                "derive-dont-store-the-active-domain",
                "adaptive-logo-shape",
                "the-distraction-problem",
                "decision-vs-delivery",
                "nearbymenow",
                "network-growth",
                "clouds",
                "directory-framework",
                "muse-system",
                "design-standards",
                "brand-system",
                "content-generation",
                "best-ai-for-the-job",
                "fact-guard",
                "location-data-enrichment",
                "weather-widget",
                "shared-business-card",
                "section-framework",
                "lead-capture",
                "leadbuilder",
                "billing",
                "money-clouds",
                "prospector",
                "marketing",
                "shortlinks",
                "growth-engine",
                "ai-postcards",
                "thanksio",
                "promo-and-offers",
                "doozer-prospects",
                "machi",
                "event-feeds",
                "indexnow",
                "ai-discoverability",
                "admin-tool-inventory",
                "doozer-digest",
                "email-tracking",
                "identity-and-auth",
                "doozer-comms",
                "regeneration",
                "atlas",
                "gatherings-and-social",
                "livemusictonight",
                "elks-root-theme",
                "niche-color-system",
                "enrichment-store-links",
                "user-hotspots",
                "postcards",
                "cloud-headers",
                "hosting-infrastructure",
                "admin-health-tools",
                "dev-api",
                "free-tier-render-gotchas",
                "the-521-root-cause",
                "shortlinks-and-qr",
                "source-of-truth-cache",
                "postcard-targeting",
                "fact-guard-identity-numbers",
                "context-city-poisoning",
                "site-creation",
                "content-generation-and-heal",
                "deployment",
                "spotcheck",
                "implicit-verification",
                "no-dark-patterns-ethos",
                "icon-taste-rule",
                "magellan-id-mapping",
                "seo-cliff-recovery",
                "muse",
                "cloud",
                "family",
                "niche",
                "magellan-and-feeds",
                "niche-matching-and-discovery",
                "two-gates-and-the-catalog",
                "photo-pipeline",
                "the-muse-v2-layer",
                "events-to-machines",
                "the-doozer-score",
                "writer-model-and-accuracy",
                "the-now-surface",
                "doozer-town",
                "chamber-chamber"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Doozer Brain\n\nThis is Doozer's knowledge in [Open Knowledge Format](decisions/okf-adoption.md) (OKF):\nplain markdown files with YAML frontmatter, linked to each other to form a knowledge\ngraph. It is the durable \"brain\" behind the platform, and it is meant to be\n**self-contained**: an AI (or a person) reading this bundle should come away\nunderstanding the whole Doozer world — the idea, the direction, the processes, and\nthe value. It is additive to `MEMORY.md` (fast recall) and `CLAUDE.md` (project\ninstructions); see [how to grow this brain](AGENTS.md).\n\n**Just upgraded to a new model?** Read **`NOTES-FOR-NEWEST-MODEL.md`** (repo root) first —\nthe living handoff letter from the previous model — then follow the reading order below.\nSee [The model handoff](processes/model-handoff.md) for the tradition.\n\n**New session? Read [Start here — working with Jon](working-with-jon.md) first** — the\nportable working agreement: how we work, the house style, keeping a brain, the admin\nskeleton, the dev channel, and the stack that's only a key away. (A verbatim portable\ncopy is at `docs/start-here-working-with-jon.md`.)\n\n## Start here (read in this order)\n\n1. [The Mission](decisions/the-mission.md) — why Doozer exists + the non-negotiable\n   quality bar (trusted / working / accurate / tasteful / useful / good).\n2. [The Doozer Story](decisions/the-doozer-story.md) — the origin and the idea.\n3. [The Architecture](systems/architecture.md) — one `index.php` serving unlimited\n   sites; what a Doozer site actually IS.\n4. [The Network](systems/the-network.md) — every product in the ecosystem and how\n   they interconnect (the breadth).\n5. [The Value Model](decisions/the-value-model.md) — how value is created and\n   captured (the economics).\n6. [The Flywheel](systems/the-flywheel.md) — how it all compounds.\n7. [Doozer vs Doozer+](decisions/two-tier-model.md) — the free/paid direction.\n8. [The Observed World](decisions/the-observed-world.md) — render-on-demand: every\n   local business exists once observed, and each materialized site is both inventory\n   and a pre-built lead (the self-funding flywheel).\n\n## Organization\n\n- **craft/** — [the clever parts](craft/index.md): the non-obvious details that make\n  Doozer different, where the interesting thing is the WHY.\n  [Category-aware call intent](craft/category-aware-call-intent.md),\n  [the hostile CSS environment](craft/hostile-css-environment.md),\n  [skeleton first, index later](craft/skeleton-first-index-later.md),\n  [self-healing email backfill](craft/self-healing-email-backfill.md),\n  [price the pull before you pull](craft/price-the-pull-before-you-pull.md),\n  [fix the source, not the symptom](craft/fix-the-source-not-the-symptom.md),\n  [inline the bytes, not the proxy URL](craft/inline-the-bytes.md),\n  [cross-root identity](craft/cross-root-identity.md),\n  [derive the URL, don't store it](craft/derive-dont-store-the-active-domain.md),\n  [the logo knows its own shape](craft/adaptive-logo-shape.md),\n  [the distraction problem](craft/the-distraction-problem.md),\n  [decision vs delivery](craft/decision-vs-delivery.md). A growing genre.\n- **systems/** — how things work:\n  [Architecture](systems/architecture.md), [The Network](systems/the-network.md),\n  [NearByMeNow](systems/nearbymenow.md), [Network Growth (\"More places nearby\")](systems/network-growth.md),\n  [Clouds](systems/clouds.md),\n  [Directory multi-view framework](systems/directory-framework.md),\n  [Muse System](systems/muse-system.md), [Design Standards](systems/design-standards.md),\n  [The Brand System (ecosystem identity kit)](systems/brand-system.md),\n  [content generation pipeline](systems/content-generation.md),\n  [Best AI for the Job (value-aware model selection)](systems/best-ai-for-the-job.md),\n  [Fact-Guard](systems/fact-guard.md),\n  [per-location data enrichment](systems/location-data-enrichment.md),\n  [Weather Widget](systems/weather-widget.md),\n  [shared business card](systems/shared-business-card.md),\n  [section framework](systems/section-framework.md),\n  [Lead Capture](systems/lead-capture.md), [LeadBuilder](systems/leadbuilder.md),\n  [billing](systems/billing.md), [Money Clouds (affiliate)](systems/money-clouds.md),\n  [Prospector](systems/prospector.md), [Marketing](systems/marketing.md),\n  [Shortlinks + QR (doozer.link)](systems/shortlinks.md),\n  [**The Growth Engine — the acquisition + monetization map**](systems/growth-engine.md),\n  [AI Postcards (Factory + Theme Studio + campaigns)](systems/ai-postcards.md),\n  [Thanks.io connector (direct mail, two-way)](systems/thanksio.md),\n  [Promo Codes + the one-time claim offer](systems/promo-and-offers.md),\n  [Hot Leads — internal Doozer-sales CRM](systems/doozer-prospects.md),\n  [Machi (the marketing brain)](systems/machi.md),\n  [Event Feeds (Marquee + the multi-source plan)](systems/event-feeds.md),\n  [IndexNow (instant Bing crawl)](systems/indexnow.md),\n  [AI Discoverability (llms.txt + AI-crawler welcome)](systems/ai-discoverability.md),\n  [Admin Tool Inventory (audit + cockpit)](systems/admin-tool-inventory.md),\n  [Doozer Digest](systems/doozer-digest.md),\n  [Email Open Tracking](systems/email-tracking.md),\n  [Identity + Auth](systems/identity-and-auth.md),\n  [Doozer Comms (Telegram)](systems/doozer-comms.md),\n  [Regeneration + Foundation](systems/regeneration.md),\n  [Atlas (outward AI)](systems/atlas.md),\n  [Gatherings + Social](systems/gatherings-and-social.md),\n  [Live Music Tonight](systems/livemusictonight.md),\n  [The Elks Root Theme](systems/elks-root-theme.md),\n  [Niche Color System](systems/niche-color-system.md),\n  [Enrichment Store Links](systems/enrichment-store-links.md),\n  [per-user hotspots](systems/user-hotspots.md), [Postcards](systems/postcards.md),\n  [cloud headers](systems/cloud-headers.md),\n  [hosting infrastructure](systems/hosting-infrastructure.md),\n  [Admin Health Tools](systems/admin-health-tools.md),\n  [Dev API + Site Quality Score](systems/dev-api.md),\n  [free-tier render gotchas](systems/free-tier-render-gotchas.md),\n  [the 521 root cause](systems/the-521-root-cause.md),\n  [shortlinks and QR (doozer.link)](systems/shortlinks-and-qr.md),\n  [source-of-truth cache](systems/source-of-truth-cache.md),\n  [postcard targeting](systems/postcard-targeting.md),\n  [fact-guard: org identity numbers](systems/fact-guard-identity-numbers.md),\n  [context-city poisoning](systems/context-city-poisoning.md),\n  and [the flywheel](systems/the-flywheel.md).\n- **processes/** — how we do things: [site creation](processes/site-creation.md),\n  [content generation and healing](processes/content-generation-and-heal.md),\n  [deployment](processes/deployment.md), [the model handoff](processes/model-handoff.md),\n  [spotcheck](processes/spotcheck.md).\n- **decisions/** — load-bearing calls: [the mission](decisions/the-mission.md),\n  [the value model](decisions/the-value-model.md),\n  [Doozer vs Doozer+](decisions/two-tier-model.md),\n  [the observed world](decisions/the-observed-world.md),\n  [implicit verification](decisions/implicit-verification.md),\n  [no dark patterns](decisions/no-dark-patterns-ethos.md),\n  [icon taste rule](decisions/icon-taste-rule.md),\n  [Magellan ID mapping](decisions/magellan-id-mapping.md),\n  [the SEO cliff and recovery](decisions/seo-cliff-recovery.md),\n  [the Doozer story](decisions/the-doozer-story.md),\n  [OKF adoption](decisions/okf-adoption.md).\n- **glossary/** — the vocabulary: [Muse](glossary/muse.md), [Cloud](glossary/cloud.md),\n  [Family](glossary/family.md), [Niche](glossary/niche.md).\n\nThe live **network** (businesses, niches, clouds, families) is emitted as OKF nodes\non demand by `includes/okf-engine.php`, browsed in Admin → Doozer Brain, and\nprojected outward per-entity as the [Atlas](systems/atlas.md). The whole bundle\nexports as one portable `.zip` (Admin → Doozer Brain → Export). No secrets live in\nthe brain by design.\n- [Magellan and the feed pond](systems/magellan-and-feeds.md) — bound external IDs (`business_id_map`) + the gcal/elks_org adapters that make sites living entities.\n- [Niche matching and live discovery](systems/niche-matching-and-discovery.md) — a niche page matches OUR tag OR Google's `types`; a niche slug is NOT a place type; the thing you asked for is never junk.\n- [Two gates, and the category catalog](systems/two-gates-and-the-catalog.md) — directory visibility vs index eligibility; the promotion step that was never written; `dz nichecatalog`.\n- [The photo pipeline](systems/photo-pipeline.md) — references are free, bytes are bought once on first view; the cache key is `md5(name + w/h)` so WIDTH is part of it, and any page can ask \"do we already own this?\" before spending. `dz imgct`.\n- [The Muse v2 layer](systems/the-muse-v2-layer.md) — the v2 render layer never applied to ANY site (token array passed where a muse row was expected); a muse is only as good as its brief. `dz muse do=tokens`.\n- [Events to machines](systems/events-to-machines.md) — a live event is only half published until a machine can read it; the schema.org Event pipeline + the timezone rule.\n- [The Doozer Score](systems/the-doozer-score.md) — Stick (cost per 100 sites), Tower (value per 100), and the leverage between them.\n- [The writer model is chosen on accuracy](decisions/writer-model-and-accuracy.md) — gpt-5.4-mini, decided by an 11-niche bake-off after the pricier model fabricated a fact.\n- [The /now surface](systems/the-now-surface.md) — one button, the nearest places, Build-it-on-the-spot; plus the measured teardown of the category page.\n- [Doozer Town and the Board Game view](systems/doozer-town.md) — self-building town clouds on doozer.town (slug = the instruction, $0 from inventory, one gated geocode otherwise), ten colour groups, any cloud as a property board.\n- [The Chamber Chamber and doozer.world](systems/chamber-chamber.md) — thechamberchamber.com is Directory Harvest's public face (roster read once, dated, sourced; members connected to their Doozer pages; emails never shown); doozer.world is the two-screen front door with thirteen honestly-labelled doors."
        },
        {
            "id": "okf-adoption",
            "title": "Adopt Open Knowledge Format for the Doozer brain",
            "type": "Decision",
            "group": "decisions",
            "description": "Why Doozer organizes its knowledge as an OKF bundle, scoped to the stable core.",
            "tags": [
                "okf",
                "knowledge",
                "decision"
            ],
            "path": "decisions/okf-adoption.md",
            "links": [
                "deployment",
                "AGENTS",
                "the-flywheel",
                "implicit-verification"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Adopt Open Knowledge Format for the Doozer brain\n\n**Decision (June 2026):** organize Doozer's internal knowledge as an\n[Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog)\nbundle (this `knowledge/` directory) and build exploration tools on it.\n\n## Why\n\n- OKF is \"just files\": markdown + YAML frontmatter in a typed tree, with markdown links\n  as graph edges. Vendor-neutral, SDK-free — a perfect fit for Doozer's\n  [FTP / no-env model](../processes/deployment.md).\n- Doozer already speaks it: the memory topic files are frontmatter-markdown, `docs/` is\n  categorized markdown, and `includes/design-md.php` already emits and serves\n  OKF-shaped `/design.md` artifacts.\n- It gives a durable, linkable \"brain\" beyond the flat `MEMORY.md` index — usable by\n  agents (future Claude) and humans alike.\n\n## Scope and caveats\n\n- Adopt the STABLE core (markdown + frontmatter + links). OKF is v0.1, so do NOT couple\n  to Google's Knowledge Catalog ingestion yet.\n- The brain is ADDITIVE: `CLAUDE.md` (instructions) and `MEMORY.md` (recall index) keep\n  their roles. See [how to grow the brain](../AGENTS.md).\n- Engine: `includes/okf-engine.php` parses this bundle, emits the live network as OKF\n  nodes ([the flywheel](../systems/the-flywheel.md)'s data), and exports a portable zip.\n  Explorer: Admin → Doozer Brain.\n\n## Branding: OKF is plumbing, Doozer Atlas is the product\n\nThe outward, AI-native emission layer is branded **Doozer Atlas** (real-world entities\ncontinuously emitting machine-readable knowledge). OKF and schema.org JSON-LD are\n*exports* of that layer, not the product. Pairs with the **Doozer Brain** (internal\nknowledge) as Brain (inward) vs Atlas (outward). The per-entity public endpoint is\n`/.well-known/doozer-atlas` (`format: doozer-atlas/0.1`); see\n[the flywheel](../systems/the-flywheel.md).\n\n## Atlas principles (the guardrails)\n\n1. Atlas emits facts, not hallucinations. AI is a CONSUMER of Atlas, not its source.\n2. Public information only. Never private contact data.\n3. Relationships are first-class objects (CONTAINS, DISCOVERABLE_IN, LOCATED_IN,\n   IN_NICHE, NEARBY, COVERS_NICHE/PLACE, HOSTS_EVENT). Density is the long-term moat,\n   but only emit edges DERIVED from real data, never invented.\n4. Atlas exists automatically for every Doozer host (business, cloud, root, VD).\n5. **Every field must justify its existence**: \"will this help a human, AI, or another\n   Doozer system understand the world around this entity?\" If no, do not emit it. Keep\n   Atlas small and intentional, not a dumping ground.\n6. Provenance is the differentiator: per-field source + an owner-verified tier (tied to\n   [implicit verification](implicit-verification.md)) is trust infrastructure nobody can\n   copy. Strengthen it over time (confidence, verified_at).\n\nArchitecture note: Atlas OWNS THE CONTRACT, not the storage. It is a DERIVED projection\nover the source-of-truth tables (always fresh, no migration, no dual-write). OKF /\nschema.org / the `/.well-known/doozer-atlas` JSON are exports of the same model. Resist\n\"Atlas owns a graph DB\" and \"site renders FROM Atlas\" (a rewrite); evolve, don't rebuild.\n\n## Deferred (roadmap)\n\nPublic publishing (`/knowledge.md` + a public explorer), a build-in-public marketing\nbeat, and a self-growing enrichment agent."
        },
        {
            "id": "two-tier-model",
            "title": "Doozer vs Doozer+ (the two-tier model)",
            "type": "Decision",
            "group": "decisions",
            "description": "The product direction splitting a free templated info page from a paid bespoke full site, and why the split exists.",
            "tags": [
                "direction",
                "strategy",
                "doozer-plus",
                "tiers",
                "product"
            ],
            "path": "decisions/two-tier-model.md",
            "links": [
                "architecture",
                "muse",
                "family",
                "section-framework",
                "billing",
                "no-dark-patterns-ethos",
                "lead-capture",
                "the-value-model"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Doozer vs Doozer+\n\nDecided July 2026. Doozer splits into two tiers that share the same\n[engine and data spine](../systems/architecture.md) but differ in ambition and\nprice.\n\n## Free — Doozer\n\nA **templated info page** the business owns: generated from Places data + reviews +\nenrichment, styled by a [Muse](../glossary/muse.md) drawn from the shared\n[family library](../glossary/family.md), assembled from the standard\n[section framework](../systems/section-framework.md). The free tier's job is to be\n*trusted, working, accurate, tasteful, useful, and good* out of the box, at fleet\nscale, for zero cost to the business. This is what 2,400+ live sites run today. The\nfree-tier rebuild (families/archetypes/structure hardening) is ongoing.\n\n## Paid — Doozer+\n\nA **bespoke full site**: a richer render layer (\"Muse+\") on the same spine, for\nbusinesses that upgrade via [Stripe billing](../systems/billing.md). Doozer+ is the\n\"WordPress-killer\" ambition: multi-section, distinctive, effectively hand-quality,\nwithout a human building it. It is a **separate render layer**, not a reskin of the\nfree page, so the free tier can stay lean while Doozer+ goes deep.\n\n## Why split\n\n- **Different jobs.** The free page must be reliable and uniform-good across the\n  whole fleet; the paid site must be singular and impressive. Optimizing one layer\n  for both compromised both.\n- **Honest upgrade path.** The free site is genuinely useful, so the upgrade is a\n  real step up, not a crippled-teaser-to-usable jump. No\n  [dark patterns](no-dark-patterns-ethos.md).\n- **Value capture follows value.** Free creates presence + [leads](../systems/lead-capture.md);\n  Doozer+ captures the businesses that want more. See\n  [the value model](the-value-model.md).\n\nShared spine, two render layers. A business is never rebuilt from scratch to move\nbetween tiers: same inputs, richer output."
        },
        {
            "id": "icon-taste-rule",
            "title": "Icon Taste Rule",
            "type": "Decision",
            "group": "decisions",
            "description": "Primary UI uses crafted SVG icons, never clipart/emoji; color is reserved for state, not for buttons.",
            "tags": [
                "design",
                "icons",
                "taste"
            ],
            "path": "decisions/icon-taste-rule.md",
            "links": [
                "shared-business-card",
                "no-dark-patterns-ethos",
                "design-standards"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Icon Taste Rule\n\n**Decision (operator, June 2026):** primary and structural UI uses CRAFTED SVG icons\n(stylish, on-brand), NEVER default plain emoji or clipart. Emoji is acceptable only as\na playful inline accent (for example the call-intent chips in a popup), not as primary\niconography.\n\nCorollary on the [shared business card](../systems/shared-business-card.md): card\noverlay icons are neutral white on the dark translucent circle; **color is reserved for\nSTATE** (a saved heart is red, a checked-in pin is green), never for the buttons\nthemselves. Tried colored outlines, reverted — color-as-state reads better and scales\nas more actions are added.\n\nSame family of taste calls as [no dark patterns](no-dark-patterns-ethos.md) and\n[Design Standards](../systems/design-standards.md): the product should never look cheap\nor machine-stamped."
        },
        {
            "id": "implicit-verification",
            "title": "Implicit Verification (no claim system)",
            "type": "Decision",
            "group": "decisions",
            "description": "Business ownership is an email match, not an account/claim flow. Load-bearing platform architecture.",
            "tags": [
                "auth",
                "ownership",
                "decision"
            ],
            "path": "decisions/implicit-verification.md",
            "links": [
                "no-dark-patterns-ethos",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Implicit Verification\n\n**Decision:** a business owner is recognized by an email match, with no claim system,\nno verification queue, no support tickets.\n\n```php\n// admin_email is AUTHORITATIVE once set; the scraped email is only the\n// implicit owner while UNCLAIMED. (Refined v2.2.396 — see the lock below.)\n$isBusinessAdmin = $business->admin_email\n    ? ($user->email === $business->admin_email)\n    : ($user->email === $business->email);\n```\n\n- `businesses.email` — the scraped canonical contact email (public). Grants ownership\n  ONLY while the listing is unclaimed (`admin_email` empty).\n- `businesses.admin_email` — the claimed / operator-set owner (never shown publicly).\n  Set by a redeemed claim code or by Doozer staff. Solves the agency case: one\n  `admin_email` across many businesses = one login to manage many.\n\n**The claim lock (v2.2.396).** Originally ownership was `email OR admin_email` — which\nlet a listing have TWO owners at once: whoever controlled the scraped inbox AND a\ndifferent person who redeemed a claim code. Now, once `admin_email` is set the listing\nis **locked to that email**: the scraped email no longer grants ownership, and a claim\ncode from a different email is refused (it cannot take over). The redeemed code records\n`redeemed_by_email` (attached to who used it). To CHANGE the email on a claimed listing\nthe owner must **request a new code**, mailed to the business address (operator voids\nthe old + clears `admin_email` so the new code can bind). The ownership test lives in\nONE place: `businessIsClaimedByViewer` / `businessOwnerAccount` (`premium.php`); the\nredeem guard is in `claimCodeRedeem` (`claim-codes.php`).\n\nLogin is the existing magic-link flow (`lb_users` / `currentUser()`); when the email\nmatches either column, the avatar dropdown reveals \"Manage [Business]\". Self-healing\nemail backfill: repeated visits from one fingerprint enqueue a priority scrape that\ntries harder to find a contact email, so \"I tried last week, it didn't work; today it\ndoes.\"\n\nA three-tier education affordance (footer utility row, an about-this-listing page, a\nsoft heuristic nudge) explains the model without nagging. This embodies the\n[no dark patterns](no-dark-patterns-ethos.md) ethos and underpins owner-facing parts of\n[the flywheel](../systems/the-flywheel.md)."
        },
        {
            "id": "magellan-id-mapping",
            "title": "Magellan — cross-service business identity mapping",
            "type": "Decision",
            "group": "decisions",
            "description": "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.",
            "tags": [
                "magellan",
                "identity",
                "ids",
                "architecture",
                "data-driven",
                "marquee",
                "atlas"
            ],
            "path": "decisions/magellan-id-mapping.md",
            "links": [
                "livemusictonight"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Magellan — cross-service business identity mapping\n\n**Decision:** Doozer is a data-driven ecosystem. A business is cross-linked to any external\nservice by its **verified ID for that service**, never by matching name + city + state. The\nmapping layer is **Magellan**.\n\n## Why\n\nName/city/state matching guesses (\"The Stone Pony\" vs \"Stone Pony\"), produces duplicates, and\nbreaks silently. Every business already has a Google Place ID; venues built from\n[Marquee](../systems/livemusictonight.md) have a Ticketmaster venue ID. The reliable join is\nthe ID. Magellan makes that the only join.\n\n## What Magellan does\n\n- Maps each Doozer `businesses.id` to its identity in every service it participates in:\n  Google Places, Ticketmaster, and any future API/resource.\n- Knows the **expected** set of IDs for a business (from its origin/niche) — a Marquee music\n  venue must have a TM id; a discovery-built plumber need not — and reports the gaps.\n- Surfaces mapping issues: missing-but-expected, conflicts, duplicates, unverified.\n- Lets the operator search + tag IDs **without knowing any raw ID** (resolve by\n  name → candidate lookup → confirm, or pull from the service's own API).\n- Every build path writes its known IDs at creation; the [Atlas](../systems/livemusictonight.md)\n  emits them as entity identifiers so AI/agents cross-reference the same way.\n\n## Shape (intended, not built)\n\nA `business_id_map` join table — `(business_id, service, external_id, source, confidence,\nverified_at)`, unique per `(business_id, service)`, indexed on `(service, external_id)` for\nreverse lookups — generalizing the existing hot columns `businesses.google_place_id` and\n`businesses.tm_venue_id`. Backfill from those; then retire the name-match fallback in\n`_vdLinkVenues`.\n\nStatus: **directive recorded, not built.** See `project_magellan` in the memory layer."
        },
        {
            "id": "no-dark-patterns-ethos",
            "title": "No Dark Patterns (the Doozer ethos)",
            "type": "Decision",
            "group": "decisions",
            "description": "Pure intent, organic, intelligence-driven. The lens for every product and ranking decision.",
            "tags": [
                "ethos",
                "principles",
                "decision"
            ],
            "path": "decisions/no-dark-patterns-ethos.md",
            "links": [
                "implicit-verification",
                "design-standards",
                "icon-taste-rule"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# No Dark Patterns\n\nThe guiding ethos. Doozer is built with **pure intent**: no popups, no deceptive ads or\ntechniques, no malware, no junk. \"Like shopping in the organic aisle.\" Intelligence is\nused to find the diamonds in the rough and the needles in the haystack, and to serve the\ntwo parties that matter: the customers and the businesses.\n\nThis is the lens for product AND ranking decisions, not a single feature. It connects to:\n\n- [Implicit verification](implicit-verification.md) — no account walls, no claim\n  upsell; we recognize owners and help them.\n- [Design Standards](../systems/design-standards.md) — tasteful, readable, never\n  AI-slop; fix root causes, not bandaids.\n- The mission: a *new web* in the GeoCities spirit, safe and useful, every site its own.\n\nSibling rules with the same spirit: the [icon taste rule](icon-taste-rule.md), and \"no\nem dashes in user-visible copy\" (so it does not read as machine-written)."
        },
        {
            "id": "the-cloud-fabric",
            "title": "The Cloud Fabric — automatic clouds over the observed network",
            "type": "Decision",
            "group": "decisions",
            "description": "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.",
            "tags": [
                "clouds",
                "strategy",
                "network",
                "observed-world",
                "real-estate",
                "awesomeaddresses",
                "seo",
                "magellan",
                "thanksio",
                "cross-linking"
            ],
            "path": "decisions/the-cloud-fabric.md",
            "links": [
                "the-observed-world",
                "the-value-model",
                "seo-cliff-recovery",
                "the-mission",
                "thanksio",
                "project_new_roots_roadmap",
                "project_prospector_status",
                "project_magellan"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Cloud Fabric\n\n**Captured with Jon, Jul 7 2026.** The strategic thesis that ties the whole\nnetwork together. Read alongside [the observed world](the-observed-world.md),\n[the value model](the-value-model.md), [SEO cliff](seo-cliff-recovery.md), and\n[the mission](the-mission.md).\n\n## The mental model\nAssume every local business already has a Doozer site — \"painted on demand\" the\nmoment it is observed (the observed-world pivot). Once that is the substrate,\nthe interesting value is not the individual page, it is the **relationships\nbetween pages**. Those relationships are Clouds.\n\nClouds form around three axes, and the third is the differentiator:\n1. **Topic** — food, bowling, golf, arcades, garden centers, specialty markets,\n   farmers markets, churches, shopping, services. Any niche, any grain.\n2. **Area** — a town, a county, a region.\n3. **Non-obvious connection** — the businesses you need *together* for a real\n   task, even across niches. This is the insight.\n\n## The load-bearing example: the real-estate transaction\nThe businesses you touch buying a home are not one niche — they are a workflow:\nrealtor's office → real estate lawyer → title / escrow agency → town hall →\ncounty clerk → local post office → and \"where do we eat lunch in our new town.\"\nThat bundle is a Cloud. Two shapes:\n- **Personal** — a buyer's private bookmark cloud for *their* transaction (My\n  Doozer Cloud over saved businesses), later including their favorite\n  AwesomeAddresses homes.\n- **Public niche cloud** — a \"moving to <Town>\" / \"real estate in <Town>\" cloud\n  any agent or buyer can use, per city/town, indexable and shareable.\n\nReal estate is also the **wedge for monetization**: realtors are heavy direct-\nmail buyers, so they are prime [Thanks.io](../systems/thanksio.md) claim / Doozer+\ntargets *now* (record_types onlybusinesses in a farm area, mail them their site).\nWhen [AwesomeAddresses](../../project_new_roots_roadmap.md) (Zillow-style\nresidential root) launches, the whole real-estate professional graph — listing\nagents, buyer agents, developers, brokers, title, lenders — becomes a first-class\naudience working inside that system.\n\n## Why this points at success (the compounding)\n- **Self-building** — observed-world paints the sites; Prospector / Cloud Factory\n  / Thanks.io radius buys grow inventory without hand work.\n- **Clustered** — [Cloud Factory](../../project_prospector_status.md) already does\n  niche+region → cloud + sweep + build + auto-join. Automatic + recipe-driven\n  clouds are the next grain (multi-niche bundles like the transaction cloud).\n- **Cross-linked** — clouds are genuine internal links between real pages;\n  [Magellan](../../project_magellan.md) makes every cross-link by stable ID, and\n  domains/subdomains/roots interlink through shared chrome (network footer,\n  neighborhood rails). Link equity flows where value is.\n- **Claimable + valuable** — every node is a pre-built lead; the CLAIM→EARN funnel\n  turns attention into owners and Doozer+ revenue. Clouds raise the value of each\n  node (context, referrals, \"more from the neighborhood\").\n- **Indexed** — clouds are legitimately useful, curated pages that deserve to rank.\n\n## The guardrails that decide success vs. cliff (do NOT skip)\n1. **Quality-gated indexing.** The [SEO cliff](seo-cliff-recovery.md) proved that\n   mass-indexing thin, templated, scaled pages gets the WHOLE network\n   algorithmically demoted. \"Indexed by search engines\" must mean: index only\n   observed + enriched + genuinely useful pages and clouds; keep thin/unclaimed\n   inventory out of sitemaps until it earns its place. Clouds help (curation +\n   real internal links) ONLY if the clouds themselves are substantive, not empty\n   niche×town permutations. Auto-cloud generation must have a content floor.\n2. **No slop, taste first** ([the mission](the-mission.md)) — a cloud with three\n   junk members is worse than no cloud. Curation and a membership quality bar.\n3. **Cost discipline** ([[feedback_api_costs]]) — painting-on-demand and radius\n   buys spend money; gate behind observation/demand, reuse cache, never mass-build\n   speculatively.\n4. **Ethics** ([[project_doozer_ethos]]) — organic value, no dark patterns; mail\n   and clouds must serve the business and the user, not trick either.\n\n## Packs — the multi-niche recipe (SHIPPED v2.2.375)\nA **pack** is a curated set of Google Places types that belong together by\nPURPOSE. `includes/cloud-packs.php` ships **31 hand-authored packs** referencing\nreal `niche_mappings.google_type` values, each with a **quality gate**\n(`min_reviews`, `require_location`, `min_rating`, and `open_247` for the 24/7\npack) and, where a single pro naturally owns the workflow, **keeper-eligibility**.\nExamples: Real Estate Transaction, Wedding, New in Town, Movers & Relocation,\nHome Improvement, Money & Advice, Final Arrangements (the 7 keeper packs), plus\nMe Time, Med Near Me, Lessons & Growth, Get Moving, New Parent, Pet Parent, Where\nto Eat, Coffee & Cowork, Sweet Tooth, Farm to Table, Date Night, Family Fun, Night\nOut, Fresh Air, On the Water, Green Thumb, Two Wheels, Local Boutique, Auto Care,\nInk & Adornment, Smoke & Vape, Culture & Books, Faith & Worship, Open 24 Hours.\n`cloudPackPreview($key,$city,$state)` is a COST-FREE count of existing Doozer\nmembers a pack would gather in a town, with a `meets_floor` flag — the content-\nfloor guard so we never build/index a thin pack cloud. Ops: `dz packs\nlist|keeper|show|preview|build|keeper_offer|set_keeper`. Membership = niche-set +\ngeo — the cloud engine already supports it (cloud_type=niche, `criteria.niches`\nis an array). **`cloudPackBuild($pack,$city,$state)` SHIPPED**: creates the live\ncloud at `<pack>-<city>-<st>.doozer.cloud`, cost-free from existing members,\nidempotent, `fill=1` sweeps below-floor niches (`prDiscoverySweep`, confirm-gated).\nProven end to end (Real Estate / West Milford NJ, live). WATCH-ITEM: raise the\ncontent floor above the demo's 4 and gate INDEXING (sitemap) on a higher quality\nbar than build, so thin pack clouds render but do not get indexed until filled.\n\n## The Keeper — territorial exclusivity as the sale\nKeeper-eligible packs are monetized by **exclusivity**: one professional becomes\nthe KEEPER of that pack cloud in their city/region — their name, brand, and\nreferral position across the whole workflow bundle. One keeper per pack per\nterritory. The pitch (esp. realtors): \"Own the Real Estate cloud for your\nterritory. Every buyer and seller in town sees you first.\" Offer it to every pro\nin the farm area (Thanks.io radius, `record_types=onlybusinesses` + real estate\nniche); one takes it. Mechanically the Keeper = the cloud's paid sponsor/owner\n(clouds already carry `sponsor_name/url/logo` via `cloudSetSponsor`, and the\nobserved-world claim path grants ownership) sold as a Doozer+/premium position.\nAny realtor pitch MUST lead with claiming their territory — scarcity closes it.\n\n## Sequence (how we get there)\n- NOW: multi-niche **cloud recipes** (transaction cloud as the first) on the\n  existing cloud engine + Cloud Factory; Thanks.io realtor claim campaigns; keep\n  indexing quality-gated.\n- NEXT: automatic cloud formation (topic/area detection over observed businesses),\n  personal transaction clouds in My Doozer Cloud.\n- LATER: AwesomeAddresses homes as cloud members + the full real-estate pro graph.\n\nThe direction is sound because the machinery mostly exists (observed-world, cloud\nengine, Cloud Factory, Magellan, claim funnel, Thanks.io). The work is composing\nit into recipes + automatic formation, and holding the quality/indexing line so\nthe network compounds instead of cliffs."
        },
        {
            "id": "the-doozer-story",
            "title": "The Doozer Story",
            "type": "Decision",
            "group": "decisions",
            "description": "The canonical WHO/WHAT/WHEN/WHERE/WHY/HOW of Doozer, distilled. The single source the Postcards, landing copy, and press materials draw from.",
            "tags": [
                "story",
                "brand",
                "mission",
                "canon"
            ],
            "path": "decisions/the-doozer-story.md",
            "links": [
                "postcards",
                "okf-adoption",
                "muse-system",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Doozer Story\n\nThe distilled canon, written from June 2026. The expressive, evolving form is\n[Postcards](../systems/postcards.md); this is the spine they hang on.\n\n**WHO.** Doozer is a BerrySmart product, built by one person: Jon Berry, solo founder and\noperator, hands-on and technical. Named after the tireless little builders from Fraggle\nRock. Each Cloud has a Keeper; NearByMeNow carries the traffic; the Muse gives each site\nits voice.\n\n**WHAT.** A domain-driven platform that turns unused or forwarded domains into useful,\nfindable single-page websites for real local businesses. One codebase, 2,400+ live sites,\nevery one different. Around it: NearByMeNow (directory), Doozer.Cloud (curated Clouds),\nmy.doozer.cloud (a person's saved places), and the coming GoHe.re (gatherings) and wuz.ai\n(memory) layers. LeadBuilder is a sibling. [Atlas](okf-adoption.md) makes it all legible\nto machines.\n\n**WHEN.** Built around turning fifty, as a swan-song legacy project, in the margins of a\nfull life: early mornings and late nights, between full-time work and raising a family.\nAnd built for a specific moment in history: when AI answer engines replaced the page of\nblue links and the question changed from \"what ranks\" to \"what gets cited.\"\n\n**WHERE.** Bootstrapped, no outside funding, answerable only to its users and its\nstandards. Deployed by hand from a laptop. No office, no team. The whole company fits in\na backpack.\n\n**WHY.** Local businesses should not lose to AI; they should be found by it. The bakery,\nthe welder, the tailor, the lodge hold neighborhoods together and are exactly the ones\nleast likely to have a current site, which now means least likely to be found. Doozer\npoints the same AI the other way. The bar is taste: useful, accurate, tasteful, no dead\nends, no AI-slop, no dark patterns. \"We Build Because.\"\n\n**HOW.** One codebase; the site is built on the fly from the domain used to reach it.\nSix steps, most under a minute: point, find (Google Places), enrich, match (a\n[Muse](../systems/muse-system.md) from a library of 100), compose (AI prose plus a guarded\nlayout pipeline), improve (every signal feeds back). Written to self-heal, because usually\nno one is watching.\n\n**VISION.** Seven surfaces, one universe, a [flywheel](../systems/the-flywheel.md) where\neach layer makes the next more valuable. The real, local, physical world deserves a web\nthat is on its side. Built one careful piece at a time."
        },
        {
            "id": "the-mission",
            "title": "The Mission (the north-star quality bar)",
            "type": "Decision",
            "group": "decisions",
            "description": "Why Doozer exists and the non-negotiable quality bar every site must clear — the lens for every build decision.",
            "tags": [
                "mission",
                "quality",
                "north-star",
                "values",
                "why"
            ],
            "path": "decisions/the-mission.md",
            "links": [
                "fact-guard",
                "lead-capture",
                "location-data-enrichment",
                "muse-system",
                "design-standards",
                "no-dark-patterns-ethos",
                "the-value-model"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Mission\n\nDoozer is BerrySmart's (Jon Berry's) legacy work: a serious attempt to give every\nsmall business a web presence that is genuinely good, generated automatically, at a\nscale and price no human-built approach can match. It is built with pure intent,\nnot extraction. The tagline is **\"We Build Because.\"** (never invent others).\n\n## The quality bar (non-negotiable)\n\nA site ships only if it is all of:\n\n- **Trusted** — accurate, honest, no misinformation. See [fact-guard](../systems/fact-guard.md).\n- **Working** — fast, live, no broken states, no [dead ends](../systems/lead-capture.md).\n- **Accurate** — reflects the real business, [GIGO](../systems/location-data-enrichment.md).\n- **Tasteful** — distinctive and dignified, never templated slop. See the\n  [Muse system](../systems/muse-system.md) and [design standards](../systems/design-standards.md).\n- **Useful** — answers the visitor's real question.\n- **Good** — the kind of thing you would be proud to have your name on.\n\n\"No dead ends, no slop\" is the shorthand. A page that looks AI-generated (em-dashes,\nbanned phrases, generic layout) fails \"tasteful.\" A lead with nowhere to go fails\n\"working.\" A wrong lodge number fails \"trusted.\"\n\n## The ethos\n\nNo dark patterns, ever ([the ethos](no-dark-patterns-ethos.md)). The intelligence\nof the platform is aimed at finding the diamonds and needles for real people, a new\nway to think about the web: discovery and presence in service of the visitor and\nthe business, not manipulation. [Value](the-value-model.md) is captured honestly,\ndownstream of value genuinely created.\n\nThis is the lens. When a build decision is ambiguous, choose the option that keeps\nthe site trusted, working, accurate, tasteful, useful, and good. Read this before\nbuilding."
        },
        {
            "id": "the-observed-world",
            "title": "The Observed World (render-on-demand + the lead flywheel)",
            "type": "Decision",
            "group": "decisions",
            "description": "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.",
            "tags": [
                "strategy",
                "observed-world",
                "quantum",
                "lead-flywheel",
                "revenue",
                "seo",
                "why"
            ],
            "path": "decisions/the-observed-world.md",
            "links": [
                "network-growth",
                "clouds",
                "seo-cliff-recovery",
                "the-mission",
                "no-dark-patterns-ethos",
                "implicit-verification",
                "lead-capture",
                "prospector",
                "two-tier-model",
                "billing",
                "atlas",
                "money-clouds",
                "growth-engine",
                "the-value-model"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Observed World\n\n## The premise\n\nBecause of the [More Places Nearby](../systems/network-growth.md) expansion, the\nDoozer ecosystem creeps outward to include every nearby business, especially\nwithin niche: restaurants, laundromats, real estate offices, gas stations, all\nof it. View this as a live-painted world, the way an RPG renders only the pixels\nthe player can see while a whole world of data stands behind them. **Doozer, in\nspirit, already holds a directory of every local business.** A site — or a\n[cloud](../systems/clouds.md) — exists the moment it is observed. It is, in a\nway, already there.\n\nThe engine for this already exists: `resolvePlaceToEcosystem`\n([place-resolver](../systems/network-growth.md)) is skeleton-first,\nmaterialize-on-observe, defer-enrichment. The shift is mental: **stop treating\n\"sites we have built\" as the inventory.** The inventory is every business that\n*could* be rendered; built sites are just the observed, cached subset.\n\n## The two assets in one act\n\nEvery business More Places Nearby materializes is at once:\n\n1. **Inventory** — a pixel in the observed world (coverage, SEO surface, and the\n   substrate AI agents can query).\n2. **A pre-built lead** — a finished, live product sitting on a real business's\n   doorstep that they have never seen.\n\nConcept one manufactures the inventory. Concept two monetizes each unit. Revenue\nfrom the sales funds the enrichment that makes the inventory real. It is a\nself-funding loop (see the flywheel diagram this node was captured from).\n\n## The two gates (load-bearing)\n\nInfinite coverage has two failure modes; one rule closes each.\n\n- **The observation / SEO gate.** Doozer already took an algorithmic\n  scaled-content demotion once ([SEO cliff](seo-cliff-recovery.md)). NEVER\n  pre-render the latent world into crawlable pages. A page becomes real\n  (indexable, enriched) only when a genuine observer earns it. Done this way the\n  observed-world model is the *antidote* to the cliff, not a bigger version of\n  it — every indexed page has a real reason to exist.\n- **The pride / quality gate.** A latent world of thin skeletons is worthless and\n  would embarrass the brand, against the [mission](the-mission.md) and the\n  [no-dark-patterns ethos](no-dark-patterns-ethos.md). The observation itself\n  triggers enrichment proportional to interest, and no site is mailed or pitched\n  until it passes a quality bar. The two gates force the machine into quality.\n\n## The lead flywheel (how the money starts)\n\nDoozer inverts the SaaS funnel: **the site is live before the pitch.** Not \"sign\nup and we will build it,\" but \"this is your website, it is already working, claim\nit.\" Mostly assembly, because the pieces exist:\n\n- **The email is the login.** [Implicit verification](implicit-verification.md)\n  says `user.email === business.email` → business admin, no claim system. So\n  every [lead-routing email](../systems/lead-capture.md) Doozer already sends is\n  the pitch: log in with this address to claim the listing and manage leads.\n- **QR + direct mail.** The [Places address is on file](../systems/prospector.md);\n  Thanks.io mails a postcard with a QR to the live site. Instant gratification:\n  scan, see it, sold.\n- **Billing is wired.** Upgrade to [Doozer+](two-tier-model.md) at $99/yr via\n  [Stripe billing](../systems/billing.md).\n\nEconomics work at trivial conversion: a postcard is well under a dollar; $99/yr\nneeds ~1–2% to be strongly positive, and the lead-email funnel is nearly free.\nEvery new site from More Places Nearby drops a fresh prospect into the funnel —\nwhich is exactly what the new-site ping already fires on.\n\n## What changes\n\n- **Metrics.** Stop counting sites built (vanity, and past a point an SEO\n  liability). Count **observations served** and **businesses claimed**.\n- **Product identity.** Doozer is not a website builder; it is the rendering\n  engine for the local commercial world.\n- **Tools this implies** (not yet built): a unified **Demand Ledger** (fold\n  `search_demand` + `lmt_interest` + GSC impressions + nearby-taps + name-404s)\n  that decides what to paint next and what is safe to index; **build-on-query**\n  as the front door; **cloud-on-demand** criteria directories; a **reality\n  meter** per entity (how much real signal backs this pixel, gating index and\n  mail); and the [Atlas](../systems/atlas.md) extended into a queryable / MCP\n  surface so AI agents — the new observers — can ask \"does Doozer have X?\" and\n  always get \"yes, here it is.\" Being the local layer AI agents cite and route\n  through may be a bigger business than the sites.\n- **Revenue paths:** claim/upgrade on materialized sites; affiliate\n  [money clouds](../systems/money-clouds.md) conjured on demand; API/agent access\n  to the queryable world; the demand ledger itself as market intelligence.\n\n## Build order (cheapest-first)\n\n1. **Step 0 — claim CTA in lead emails. SHIPPED v2.2.355.** `nbmnLeadEmailBusiness()`\n   appends a \"claim your listing\" block (their live site + the email-is-the-key\n   pitch); the [claim landing](../systems/lead-capture.md) at `?page=claim&b=<id>`\n   (`includes/claim-listing.php`) shows the owner their live site and offers a\n   one-step magic-link login (existing `claim` purpose + `?ajax=magic_link_send`),\n   with a soft Doozer+ $99/yr upsell.\n   **The CLAIM to EARN half, shipped v2.2.371.** A claim now delivers real value\n   and the upgrade actually bills. (a) Enrichment surfaces the scraped\n   `contact_email` onto the empty `businesses.email` column\n   (`performBasicEnrichment`) — without this, an emailless-but-scraped business\n   could never match on implicit verification, so no re-scan could ever unlock a\n   claim. (b) `?page=plus` (`includes/plus-landing.php`) is a real Doozer+ page\n   telling the two value stories and starting Stripe checkout (sku\n   `premium_yearly`, $99/yr, code `LAUNCH20`); previously it dead-ended on the\n   landing page. (c) Ads come off a page the moment it is claimed (an account\n   exists for the contact/admin email) or when the owner is viewing —\n   `businessAdsSuppressed()` + friends in `premium.php`, hooked into\n   `business-site.php`'s `$showAdsense`. Ads are the monetization of UNCLAIMED\n   inventory only. (d) `includes/manage-business.php` (`?page=manage&b=<id>`,\n   owner-gated) is the \"Manage your site and leads\" surface — ad-free\n   confirmation, editable phone/website, leads inbox. (e) The claim page's\n   non-match state has \"I added it, re-scan my site\" → `?ajax=claim_rescan`\n   (priority `basic_enrichment`, rate-limited) so an owner can add their email\n   and unlock the claim. Still NO claims table — \"claimed\" is derived from an\n   `lb_users` email match, exactly the implicit-verification model.\n2. **Step 1 — QR + claim/upgrade landing** on top of existing Doozer+ / Stripe.\n3. **Step 2 — Thanks.io direct mail**, gated on the reality meter (only mail a\n   site worth pride).\n4. **Alongside — the Demand Ledger**, the brain that says what to materialize and\n   what is safe to index.\n\nCaptured July 5, 2026 (with Jon) as a strategic pivot to examine for value and\nrevenue. **Realized by [The Growth Engine](../systems/growth-engine.md)** — the\nconcrete observe→serve→convert→learn machine (postcards, offers, Hot-Leads CRM).\nRelated: [the value model](the-value-model.md),\n[two-tier model](two-tier-model.md), [SEO cliff](seo-cliff-recovery.md),\n[no dark patterns](no-dark-patterns-ethos.md)."
        },
        {
            "id": "seo-cliff-recovery",
            "title": "The SEO Cliff and Recovery (why we noindex until quality-gated)",
            "type": "Decision",
            "group": "decisions",
            "description": "The May 2026 elks.club algorithmic demotion for scaled thin content, and the enrich-then-index discipline it burned into the platform.",
            "tags": [
                "seo",
                "quality",
                "elks",
                "scaled-content",
                "noindex",
                "lesson"
            ],
            "path": "decisions/seo-cliff-recovery.md",
            "links": [
                "network-growth",
                "location-data-enrichment",
                "muse-system",
                "regeneration",
                "fact-guard",
                "the-mission"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The SEO Cliff and Recovery\n\nAround **May 22-24 2026** the elks.club fleet fell off a search cliff. Diagnosis\n(load-bearing): it was an **algorithmic scaled-content demotion**, NOT a manual\npenalty, a 521 outage, or a deindex. westmilford.biz, on the same infrastructure,\nheld steady as the control — so the cause was content, not hosting. Google\ndemoted a large set of similar, thin, auto-generated pages.\n\n## The lesson (now a platform rule)\n\nA flood of near-identical, thin sites is a liability, not growth. Every generation\nand growth decision inherits this:\n\n- **Enrich then index.** On-demand builds (see [network growth](../systems/network-growth.md))\n  ship as a skeleton that is **`noindex` until it is enriched AND passes a quality\n  gate**, then flips to index + sitemap. Never dump bare skeletons into the index.\n- **Variety and voice.** Thin sites are fixed with BETTER DATA and real\n  [enrichment](../systems/location-data-enrichment.md), not tone. The\n  [Muse system](../systems/muse-system.md) + families exist so sites don't read as\n  templated slop.\n- **Quality gate has teeth.** [Regeneration](../systems/regeneration.md) is\n  quality-gated with rollback; the \"More places nearby\" build bar keeps genuinely\n  thin places out.\n- **Dedup + accuracy.** [Fact-Guard](../systems/fact-guard.md) and canonical\n  handling keep pages distinct and correct.\n\nRecovery is content quality + dedup + a quality-gated index, not a hosting fix.\nThis is why \"make it work fast\" is always balanced against \"no slop, no dead ends\"\n([the mission](the-mission.md)) — the cliff is the proof of what happens when it\nisn't."
        },
        {
            "id": "the-value-model",
            "title": "The Value Model (how Doozer creates and captures value)",
            "type": "Decision",
            "group": "decisions",
            "description": "Where Doozer's value comes from and how it is captured, so an AI understands the economic engine, not just the software.",
            "tags": [
                "value",
                "monetization",
                "economics",
                "strategy",
                "why"
            ],
            "path": "decisions/the-value-model.md",
            "links": [
                "no-dark-patterns-ethos",
                "the-network",
                "the-flywheel",
                "lead-capture",
                "implicit-verification",
                "two-tier-model",
                "billing",
                "livemusictonight",
                "prospector"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Value Model\n\nDoozer's premise: **most small businesses have no good web presence, and the\nmarket has failed to give them one cheaply, quickly, and tastefully.** Doozer\ngenerates one automatically from data the business already generates (Google\nPlaces, reviews, photos, its own activity). The value is created before anyone\npays, and captured along several honest paths, never with\n[dark patterns](no-dark-patterns-ethos.md).\n\n## Value created (before any charge)\n\n- A real, useful, accurate single-page site exists at a memorable domain, often\n  better than the business's outdated official site or its absence.\n- The business becomes discoverable across the [network](../systems/the-network.md):\n  NearByMeNow, Clouds, vertical directories, search.\n- Visitors get a fast, trustworthy answer to \"is this place open / good / near me,\"\n  which is the whole [ethos](../systems/the-flywheel.md).\n\n## Value captured (how money is made)\n\n1. **Lead generation** — the primary engine. Every call, message, and intent\n   signal is [captured and routed](../systems/lead-capture.md) to the business\n   through LeadBuilder. Qualified leads are the product a business will pay for\n   because they map directly to revenue.\n2. **Ownership / Doozer+** — a business [claims its site](implicit-verification.md)\n   for free and can upgrade to the paid [Doozer+ tier](two-tier-model.md): a\n   bespoke full site, priced via [Stripe billing](../systems/billing.md).\n3. **Premium features** — analytics, custom branding, agency multi-site\n   management, higher new-site caps.\n4. **Sponsorship / advertising** — tasteful, network-level placements\n   (`renderDoozerSponsorBar()`), never spammy.\n5. **Affiliate / referral** — booking, ticketing, and commerce CTAs where they\n   genuinely help the visitor (e.g. tickets on [live music](../systems/livemusictonight.md),\n   vetted \"Shop Now\" links).\n\n## Why this compounds\n\nEach captured signal makes the next site cheaper to justify and better targeted\n(via [Prospector](../systems/prospector.md) and enrichment). Value capture funds\nvalue creation, which produces more signal. That is [the flywheel](../systems/the-flywheel.md);\nthis node is its economics. The north star is never extraction: a site must be\ntrusted, working, accurate, tasteful, useful, and good, with no dead ends and no\nslop, or it is not shipped."
        },
        {
            "id": "writer-model-and-accuracy",
            "title": "The writer model is chosen on accuracy, not price",
            "type": "Decision",
            "group": "decisions",
            "description": "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.",
            "tags": [
                "ai",
                "models",
                "accuracy",
                "cost",
                "content-generation",
                "decision"
            ],
            "path": "decisions/writer-model-and-accuracy.md",
            "links": [
                "fact-guard"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The writer model is chosen on accuracy, not price\n\n**Decision:** `business_website` content generation runs on **`gpt-5.4-mini`**.\n\n## The test\n\nEleven real businesses, one per niche, each regenerated by both candidates against\nidentical source data. **Counterbalanced**: a regeneration feeds the previous copy back\ninto the prompt, so whichever model runs second sees the other's work. Five businesses\nsaw Sonnet first, six saw GPT first, cancelling the order effect. Every cost read\nper-call from `ai_generation_history`, never estimated from a price sheet.\n\n| | claude-sonnet-4-6 | gpt-5.4-mini |\n|---|---|---|\n| per site | $0.03400 | **$0.00748** |\n| **per 100 sites** | $3.40 | **$0.75** |\n| seconds | 28.7 | **6.5** |\n| output tokens | 1,335 | 973 |\n\nCheaper and faster on **every single business**, low variance in both arms.\n\n## What actually decided it\n\nCost did not. $2.65 per hundred sites does not justify changing the voice of the network.\n\nWriting for **Thessen Services**, a plumber with no website on file and no cached source,\nSonnet opened: *\"Permit files across three county offices carry the record.\"* The strings\n`permit` and `county` appear **zero times** in that business's entire stored record. The\nsentence is fluent, specific, checkable, and invented. Given identical inputs,\ngpt-5.4-mini opened with the verified street address, and across all eleven specimens it\nstayed inside known facts.\n\n**A wrong fact is worse than a missing one.** That is the platform's whole premise for\n[fact-guard](../systems/fact-guard.md) and the geo-anchor rule, and it settled a decision\nthe cost data alone would not have.\n\n## Honest limits\n\nOne observed fabrication in eleven pairs is not a measured hallucination rate. It was\nenough to act on because the failure mode is severe and the cheaper model carried no\noffsetting cost, not because eleven samples prove a rate.\n\n**What we gave up:** Sonnet writes better headlines and ~27% more copy. A split where the\ncheap model writes the body and a stronger one writes the headline is worth testing.\n\n## How to re-run this\n\n`dz aimodelreg repoint=business_website model=<id> live=1` swaps the writer;\n`dz aimodelreg sync=1 live=1` refreshes the priced catalog. **Before switching to any\nmore verbose model, check its output length against the pipeline's max-tokens cap** —\nSonnet 5 blew past 2,048 tokens, the JSON truncated, and it silently blanked two live\npages before `_regenContentIsSubstantive()` existed to catch it.\n\nCase study (all 22 generations readable side by side):\nhttps://claude.ai/code/artifact/a9ac8ae5-3bc9-477f-b731-b99950afe488\n\nRelated: [[magellan-and-feeds]], [[the-doozer-score]], [[fact-guard]]."
        },
        {
            "id": "ai-discoverability",
            "title": "AI Discoverability — being the local layer AI agents cite",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "ai",
                "seo",
                "discoverability",
                "llms-txt",
                "robots",
                "duckduckgo",
                "bing",
                "indexnow",
                "atlas",
                "agents"
            ],
            "path": "systems/ai-discoverability.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# AI Discoverability — being the local layer AI agents cite\n\nThe [[the-observed-world]] endgame is that AI agents are the new observers: they ask \"is there a\ngood X near me?\" and Doozer should always be able to answer \"yes, here it is, with structured\ndata.\" This node is how a Doozer host makes itself legible to AI and AI-powered search.\n\n## The load-bearing insight: DuckDuckGo is Bing\n\nDuckDuckGo does **not** run its own web index. Its results are overwhelmingly **Bing-sourced**\n(plus DuckDuckBot for a little, Apple Maps for local). So \"optimize for DuckDuckGo\" mostly means\n\"be in Bing\" — which the existing **[[indexnow]]** pipe already does (instant Bing submission on\npublish; verified healthy, HTTP 202s flowing). Same logic extends to the AI assistants: ChatGPT\nsearch, Copilot, and DuckAssist all lean on Bing's index. **IndexNow is therefore the single\nbiggest AI-search lever, and it was already built.** The gap was the AI-native layer below.\n\n## The machine-readable stack (most of it pre-existed)\n\nEvery Doozer business page already emits, and now advertises, structured data:\n- **schema.org LocalBusiness JSON-LD** (`schema-org.php`) — type-specific\n  (FastFoodRestaurant, PostalAddress, GeoCoordinates, AggregateRating, opening hours). The single\n  richest signal for both rich results and AI extraction.\n- **The [[atlas]] feed** at `/.well-known/doozer-atlas` (JSON) — the per-entity outward emission,\n  linked in `<head>` via `<link rel=\"alternate\" type=\"application/json\">`.\n- **`/design.md`** (Google-Labs DESIGN.md) — agent-facing brand spec, `<link rel=\"alternate\"\n  type=\"text/markdown\">` in head, `X-Robots-Tag: noindex`.\n- **Sitemaps** + robots.txt served dynamically per domain.\n\n## What v2.2.420 added\n\n- **`/llms.txt`** (`includes/llms-txt.php`, the llmstxt.org convention) — a concise Markdown card\n  served per domain that hands an agent a plain-language \"here's what this is\" plus links to all of\n  the above. Four shapes: `master` (the network), `nbmn` (the directory), `root` (a directory root\n  like elks.club/westmilford.biz — `isRootWildcardDomain()`, reads its `root_domains` name/tagline/\n  description), and `business` (pulls real name/niche/city from `domains`→`businesses`, generic\n  fallback if unresolved). Routed in `index.php` next to robots/design.md, in the sessionless +\n  drain-skip fast paths. **Gotcha caught v2.2.421:** roots front many businesses, so they must NOT\n  fall through the `business` branch (it would mislabel a whole directory as one local business).\n- **Explicit AI-crawler welcome in robots.txt** — `doozerAiCrawlers()` (GPTBot, OAI-SearchBot,\n  ChatGPT-User, ClaudeBot, Claude-SearchBot, PerplexityBot, Google-Extended, Applebot, Bingbot,\n  DuckDuckBot, DuckAssistBot, Amazonbot, Meta-ExternalAgent, CCBot, …). `User-agent: *` already\n  allowed them; naming them is an intentional \"yes, index us\" (most of the web blocks these).\n  Emitted only on business + NBMN (no Disallows to bypass); the master keeps `*` authoritative so\n  its admin Disallows bind every bot, and just advertises the llms.txt summary line.\n\n## The stance (a choice, not a default)\n\nMost sites are racing to **block** AI crawlers. Doozer does the opposite: the whole model is that\nevery observed business should be findable, and being the structured local layer AI agents cite and\nroute through may be a bigger business than the sites themselves ([[the-observed-world]]). So we\nroll out the welcome mat and hand agents citable data. Guardrail: the data must be **true and\ncurrent** before we invite citation — the quality gate ([[the-mission]]) still governs what gets\nindexed and enriched; we never advertise a thin skeleton.\n\n## Verify by looking (not the token)\n`curl` the live surfaces on a real host: `/llms.txt`, `/robots.txt`, `/.well-known/doozer-atlas`,\nand grep the homepage for `application/ld+json`. Per [[feedback_verify_by_looking]], confirm on\nlive pixels/bytes, since a legacy path can silently no-op.\n\nRelated: [[indexnow]] (Bing pipe), [[atlas]] (outward emission), [[the-observed-world]] (agents as\nobservers), [[seo-cliff-recovery]] (only index what is real).\n\n## The brain itself, for agents (Sept 2026)\n\nThe whole knowledge bundle is live at four addresses on doozer.work, rendered from the\n`knowledge/` files on every request (15-minute cache), so deploying a node updates it:\n\n| form | address | robots |\n|---|---|---|\n| **HTML index, start here** | `https://doozer.work/brain.html` | none; ~30KB, one link per node |\n| HTML, one page per node | `https://doozer.work/brain/<node>.html` | none; ~5 to 20KB each, cross-links rewritten, prev/next |\n| HTML, everything on one page | `https://doozer.work/brain-full.html` | none; ~420KB, for readers that can take it |\n| Markdown | `/brain` | noindex, noarchive (shareable, not a search result) |\n| plain text | `/brain.txt` | noindex |\n| JSON | `/brain.json` | noindex |\n\nThe HTML forms exist because most agent fetchers choke on `text/markdown`, on an\nextensionless URL, on a single 450KB document, or treat noindex as \"do not read\". The\nserver was never at fault (every form returned the right type); the SHAPE was. Give an\nagent the index and it can walk the pages it needs. The operator's rule: it is not\nopen source, and it is not the secret recipe; hand the `.html` address to an agent and\nit reads everything, with a table of contents."
        },
        {
            "id": "ai-postcards",
            "title": "AI Postcards — Factory, Theme Studio, and Campaigns",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "postcards",
                "factory",
                "theme-studio",
                "campaigns",
                "claim",
                "thanksio",
                "direct-mail",
                "vision-gate",
                "audience",
                "ai-image"
            ],
            "path": "systems/ai-postcards.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# AI Postcards + the Postcard Factory\n\n> **v2.2.381 — the Factory is now the default, accurate path.** Generating the\n> whole card with AI every time was expensive AND inaccurate (it approximated the\n> claim code, invented the short URL, hallucinated the domain in the screenshot's\n> address bar). The **Postcard Factory** (`includes/postcard-factory.php`) flips\n> it: AI designs backgrounds/layouts; every ACTUAL card is composed\n> deterministically from REAL values and flattened to a print-ready 1800x1200 PNG\n> via **Cloudflare Browser Rendering** (`postcardFactoryFlatten` posts `html` +\n> viewport to the same CF endpoint used for screenshots). Real screenshot in a\n> browser chrome showing the ACTUAL Doozer domain, real minted claim code, real\n> doozer.link short URL, a real scannable QR, real logo, Machi copy. No per-card AI\n> cost, nothing to proofread. `postcardFactoryCompose($bizId,$opts)` files it in\n> the same review queue (no gate — values are deterministic). Themes: paper / navy.\n> Admin: AI Postcards page \"Build card\" panel; Machi defaults to engine='factory'.\n\n> **v2.2.395 — Multi-campaign system.** Postcards are no longer claim-only. A\n> **campaign** = purpose + audience + copy/offer (`postcard_campaigns`,\n> `includes/postcard-campaigns.php`). The seeded, locked **claim_your_site**\n> campaign (audience=unclaimed) IS the old behavior, now named. `campaign` already\n> threaded through postcard_designs/thanksio_sends/claim_codes/machi_touches as a\n> string; this formalizes the keys. **Audiences** (hard-coded): all / unclaimed\n> (admin_email empty) / claimed (admin_email set) / premium (owner lb_user on a\n> premium-class tier), each + niche (primary_niche) + location (state_code/city);\n> base = live Doozer site + mailable address. `postcardCampaignAudienceWhere/Count/\n> Sample`. **Purpose seeds generation:** `postcardCampaignThemePromptSeed` feeds the\n> Theme Studio background prompt (`postcardThemeCreate` opt `campaign`);\n> `postcardCampaignMessage` resolves the BACK copy (claim → warm/leads/curious\n> variants; else message_template with tokens {name}{city}{code}{offer_code}\n> {short_url}). Admin: Campaigns cards + Create/Edit modal (live audience count),\n> Campaign selector on Factory + Studio, review queue pagination + finder (search +\n> campaign filter). `dz campaign do=list|show|save`. NEXT phase: campaign-specific\n> FRONT copy + audience-gated sending (don't mail a claimed business a claim card).\n\n> **v2.2.392 — Two-score gate + back copy + Thanks.io levers.** The theme gate\n> (`postcardThemeGateCard`) now returns TWO independent real scores because there is\n> no in-admin way to tweak a theme after the fact: **accuracy** (every required\n> element present, uncropped, legible — an explicit checklist is passed to the vision\n> model) and **design** (aesthetics + theme match only). A theme goes ACTIVE only\n> when BOTH clear `postcard_theme.gate_pass_score` (default 78); else `draft`. Stored\n> in `postcard_themes.design_score` / `accuracy_score` (idempotent ALTER); shown in\n> the Studio result and gallery tiles. Card template fix: `.left` scrim is full grid\n> height (`grid-template-rows:100%`) so the \"doozer.\" wordmark is always inside the\n> legibility box. **Back-of-card copy** (`thanksIoClaimMessageVariants` in\n> `thanksio.php`): warm / leads / curious variants, personalized with the town + a\n> typed `doozer.link` fallback, no em-dashes. **Thanks.io best practice:** the FRONT\n> is the Factory image; the BACK is `message` + `qrcode_url` + return address. Turn\n> on `thanksio.handwriting_style_id` (handwritten back lifts response), set\n> `thanksio.size` (4x6 default; 6x9/6x11 stand out but need a matching front aspect),\n> `thanksio.message_variant`. Rules that win direct mail: personal, specific to them,\n> ONE clear action (scan), readable in three seconds, honest, warmly signed.\n\n> **v2.2.390 — AI Theme Studio.** The untrustworthy full-AI card painter is\n> repurposed into a theme/background designer only; the Factory still does the real\n> assembly. `includes/postcard-theme-ai.php`: `postcardThemeCreate($style,$opts)`\n> takes a plain-words style (\"beach vacation\", \"Thanksgiving warm orange\",\n> \"Bronx blue/white pinstripe\") + optional `business_id`/`match_site` for niche\n> style cues, builds a LAYOUT-AWARE background prompt (calm left third for text,\n> calm bottom-right ~1.5in for QR, right half supporting the screenshot, NO\n> text/letters/logos), generates the bg via the configured image provider\n> (`postcardAiImageProvider`), then a **vision pass reads the palette BACK from the\n> pixels** (`postcardThemeReviewBg` → accent/ink/dark, reads `raw_text`). It then\n> composes a REAL sample card (live screenshot + demo code/QR) and runs a second\n> **vision gate** (`postcardThemeGateCard`) for legibility/layout/design; score\n> >= `postcard_theme.gate_pass_score` (default 78) saves `status=active`, else\n> `draft`. Themes are data in **`postcard_themes`** (theme_key, source\n> seed/ai/operator, style_prompt, bg_type color|image, bg_value, accent/ink/dark,\n> status, gate_score/reasons, preview_url); `postcardFactoryThemes()` merges seeds\n> (paper/navy/kraft) + active DB rows so a passing theme is instantly pickable in\n> the Factory. Image backgrounds get an auto legibility scrim behind the left text\n> column (`postcardFactoryHtml` `$bgIsImage`). `postcardFactoryHtml(...,$themeSpec)`\n> takes a candidate spec so the Studio can preview before saving.\n> Dev API: `dz factorytheme do=create style=\"...\" [b=<id>] [match=1]`,\n> `do=status key= set=active|rejected`, `do=list`. Admin: AI Postcards page has the\n> AI Theme Studio card (style + business picker + match-site) and a theme gallery\n> (activate/retire). The AI Postcards business picker uses the same\n> `admin_business_search` autocomplete and its dropdown is opaque. First run:\n> \"beach vacation\" scored 92, shipped a genuinely good card (\"Tropical Escape\").\n> `dz factory compose b= [theme=] [angle=]`. The old full-AI painter (below) is the\n> fallback (engine='ai'), kept for background-art experiments.\n>\n> **Themes (v2.2.382):** paper (light editorial), navy (bold dark), kraft (warm\n> local). Theme colors derive from a `dark` flag + optional panel/muted/chrome\n> overrides — adding a theme is a few lines in `postcardFactoryThemes()`. Layout:\n> local eyebrow + headline that **auto-sizes to its length** (any Machi angle fits\n> the fixed 6x4 card) + screenshot as a real `<img>` in a `overflow:hidden` crop\n> box (full nav, no white gap) inside browser chrome showing the real domain.\n> Render notes: QR inlined as a data URI (external QR service timed out the\n> browser); `networkidle2` + paint delay; 429 backoff-retry.\n>\n> **Load-bearing bugfix shipped here:** `claimCodeCreate()` validated the DEFAULTED\n> intent but assigned the RAW `$opts['intent']`, so any caller not passing intent\n> (every postcard) failed with \"Column 'intent' cannot be null\" and got no code.\n> The AI cards hid it by inventing a code; the Factory surfaced it. Real codes now\n> mint network-wide.\n\n## Sent & performance tracking + independent resend (v2.2.407)\n\nEvery send already mints its OWN claim code + doozer.link shortlink + Thanks.io\n`order_id`, so each touch is independently trackable — the schema never limited a\nbusiness to one card. The admin Sent section (v2.2.409) is bounded so it never runs\nwild at scale: three **last-5 glances** (Sent / Delivered / Touched = scan·visit·claim)\nplus a full **All sends** table with search (business/code), campaign + status\n(delivered/scanned/claimed) filters, sort-by-date, pagination, and a **Doozer+ column**\n= full conversion (claimed + owner on a premium tier).\n\n- **`postcardDesignLiveStats(array $design)`** (`postcard-ai.php`) reads outcomes LIVE\n  from `thanksio_sends` (cost/order/preview), `thanksio_events` (delivered + QR scans),\n  `shortlinks.click_count` (visits), `claim_codes.redeemed_at` (claimed). Never the cached\n  JSON. `postcardAiTracking()` delegates to it + caches. The old code queried a\n  non-existent `shortlinks.visits` column (always 0) — fixed to `click_count`.\n- **Webhook** (`thanksIoHandleWebhook`) recomputes the linked design's tracking on every\n  delivered/scan event, so the view stays fresh without a manual Refresh.\n- **Resend:** `?ajax=resend` on `admin-postcards-ai.php` re-runs `postcardFactoryCompose()`\n  for the same business + campaign + theme (theme recovered from `model` = `factory:<key>`)\n  → a NEW `pending_review` card with a fresh code. Never auto-mails; operator Previews /\n  Sends Real. Delivered/scan status fills in as Thanks.io reports it (minutes→days).\n- **View front + back (v2.2.410):** each send's \"View\" button opens a lightbox with the\n  Factory front + the real Thanks.io handwriting back. Proofs persist to\n  `postcard_designs.proof_urls` on preview/send; `?ajax=proof` fetches them free on demand\n  for older sends (a preview of a `sent` card is allowed — free, no mail) and caches. NOTE:\n  Thanks.io previews live at `response.data.previews` (front + handwriting back, S3 URLs).\n- **Cost bug FIXED (v2.2.409):** Thanks.io returns `authorization_total`/`leads_fee` ALREADY\n  in cents (114 = $1.14 for a 4x6, 1 recipient); the send path was multiplying by 100 again.\n  Dropped the `*100` (thanksio.php ~357); `dz postcard do=fixcosts` re-derives historical rows\n  from raw meta.\n- **No stray logo (v2.2.414):** the Factory top-right \"logo\" was `businesses.logo_url`, an\n  unreliable scrape (often a site PHOTO, not a logo). Slot removed (`$logoBlock=''`); the\n  screenshot already shows real branding. Re-enable only behind a trustworthy is-a-logo signal.\n- **Size is a variable (v2.2.414):** 4x6 and 6x9 share the SAME 2:3 art (1800×1200), so no\n  re-render — pick per send via the review-card selector (4x6/6x9/6x11). Plumbs postcardAiSend\n  → thanksIoMailClaim → Thanks.io; recorded on `thanksio_sends.size`.\n- **Format science (v2.2.414):** `postcardFormatPerformance($niche,$state,$days)` + a \"Format\n  performance\" admin panel — funnel (sent→delivered→scanned→claimed→Doozer+ converted) by SIZE\n  as a rate of sent, sliceable by niche/state/timeframe, with cost/claim + a thin-sample flag\n  (n<30). Claim rate is the money metric.\n\n## Roadmap (the Marketing Engine this seeds)\n\n- **Phase 2 — Internal LeadBuilder / \"Doozer awareness\" CRM:** businesses are OUR\n  prospects; every touch (postcard sent/delivered/scanned/claimed, site created, leads we\n  deliver to them) rolls into a per-business awareness score → internal Hot Leads to convert\n  free→Doozer+→Keeper→agency. Reconcile with [[machi]] rather than duplicate.\n- **Phase 3 — BYO-key thanks.io reseller Marketing Engine:** Doozer+ businesses send\n  direct-mail + AI marketing to their OWN leads. Needs a `getApiKeyForBusiness()` seam off\n  the single `getApiKey()` chokepoint, per-business thanks.io key, owner send tool on\n  `manage-business.php`, a 50% affiliate revenue ledger, agency/franchise tiers. Reuses the\n  Factory + the two-score `/frontend-design` vision gate already built.\n\n---\n\n# AI Postcards — fully-generated claim mail with a quality gate (fallback painter)\n\n**What:** The physical arm of the observed-world flywheel. When a business has no\nemail on file (unclaimable via implicit verification), we mail a real postcard\ncarrying the site's own screenshot, a claim code, and a scannable QR. The card\nDESIGN is AI-generated end to end and passes a vision-model quality/accuracy gate\nbefore any human sees it. No templated fallback — if the AI can't produce a good\ncard in 3 tries, it fails loudly for review instead of mailing slop.\n\n**Engine — `includes/postcard-ai.php`**\n- `postcardAiGather($businessId)` — assembles real ingredients: live site\n  screenshot (`siteScreenshotUrlForHost`, Cloudflare Browser Rendering -> Spaces),\n  a minted claim code + doozer.link QR (`claimCodeCreate`), business facts, logo.\n- `postcardAiBuildPrompt($ctx)` — detailed 6x4 postcard art-direction prompt:\n  use the provided screenshot faithfully as the browser-framed hero, exact\n  business name / claim code / URL, reserve a QR square, Doozer green.\n- `postcardAiGenerate($prompt,$refImageUrls)` — Gemini `generateContent` with the\n  screenshot + logo passed as real `inlineData` image inputs (Gemini is the only\n  configured engine that accepts image inputs). Needs an image-capable Gemini key\n  in `api_keys` (service `gemini`/`google`/`google_gemini`).\n- `postcardAiGate($imageUrl,$ctx)` — SCANS the generated image with a vision model\n  (`callVisionProvider` openai/anthropic), returns JSON `{suitable,score,reasons}`.\n  Pass threshold = `platform_settings` `postcard_ai.gate_pass_score` (default 75).\n- `postcardAiRun($businessId,$opts)` — orchestrates gather -> up to 3\n  generate/save/gate attempts. Suitable design -> `pending_review`; three failures\n  -> `failed` with all three attempts + reasons kept.\n- `postcardAiSend($designId,$opts)` — mails via `thanksIoMailClaim` (front_image_url\n  = the generated card). `live` flag gates preview vs real mail (costs money).\n- `postcardAiTracking($designId)` — rolls up Thanks.io scan events + doozer.link\n  shortlink visits + claim-code redemption.\n\n**Table:** `postcard_designs` (business_id, claim_code_id, screenshot_url, logo_url,\nqr_url, redeem_url, prompt, image_url, model, attempts, gate_score, gate_verdict,\ngate_reasons, status ENUM('generating','pending_review','failed','approved','sent',\n'rejected'), failures JSON, thanksio_send_id, sent_at, tracking JSON).\n\n**Admin — `includes/admin-postcards-ai.php`** (Admin -> Infrastructure -> AI\nPostcards, route `postcards-ai`). Human-in-the-loop: ready designs show the card +\ngate score with a Send button (live checkbox); sent designs show live tracking;\nfailed designs show 3 attempts + Retry. Generation runs as a background\n`postcard_ai` job (dispatcher case), so the screen never blocks.\n\n**Dev API:** `dz postcard gen b=<id>` (queue+run), `list`, `show id=`, `send id=\n[live=1]`, `retry id=`, `track id=`.\n\n**The claim loop:** sending sets `sent` and starts the state machine; a QR scan or\ncode redemption sets the owner's `businesses.admin_email` — the postcard IS proof\nof claim (implicit verification, no claims table). See [[claim-codes]],\n[[thanksio]], [[the-observed-world]].\n\n**State:** shipped v2.2.379 (Jul 7 2026). Blocker for first real run: an\nimage-capable Gemini key must be in `api_keys`; vision gate keys (openai/anthropic)\nalready present. First target = Powell Flooring (business id 1, West Milford NJ,\nemail NULL, screenshot already captured to Spaces)."
        },
        {
            "id": "best-ai-for-the-job",
            "title": "Best AI for the Job — value-aware model selection",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "ai",
                "models",
                "cost",
                "quality",
                "scoring",
                "strategy",
                "doctrine"
            ],
            "path": "systems/best-ai-for-the-job.md",
            "links": [
                "site-scoring"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Best AI for the Job\n\nDoozer does not use one model for everything, and it does not chase the cheapest\nmodel. It uses **the best model for each job** — chosen intelligently, weighed by\nthe **value of the work against its cost**, and refined by what the work actually\nscored. \"The best man for the job\", made mechanical.\n\n## THE GOAL\n\n> Build **Solid A** rated sites — accurate, beautiful, tasteful, helpful,\n> valuable, useful — for **every business that should have one**. Par-create\n> decides who should have one. The scoring of what we build drives the resources\n> spent on the next build. The fleet average climbs generation over generation.\n\nSolid A = weighted **≥ 85** across the [six goals](site-scoring.md)\n(`dzQualityGoal()`, tunable `platform_settings.doozer_quality_goal`). This is not\na per-site retry loop — we build sites one after another, each using the current\nbest-known strategy, and the results steer the next build. When a choice raises a\nniche's average we keep it; **when a choice regresses it, we undo that choice and\ntry another until the score climbs.** Doozer builds because — and it gets better\nevery generation, toward leading-edge, lead-generating sites at scale.\n\n### The self-improving ratchet (`quality-autopilot.php`)\n\nThe unit of improvement is the **niche** — sites within a niche score alike, so a\nstrategy that lifts one lifts them all. Each niche holds a `niche_build_policy`\n(the current strategy: model tier, deep-facts on/off, the baseline it's beating).\n`qualityRatchetTick()` (`dz goal autopilot=1 go=1`):\n\n1. A niche **below the goal** and un-escalated → escalate its next builds (GO HARD\n   + deep source-of-truth). Record the baseline to beat.\n2. An **escalated** niche → compare its fresh average to the baseline:\n   improved → **commit** (new baseline, keep climbing); regressed → **revert** the\n   choice and mark that lever \"tried, didn't help\" so the next pass reaches for a\n   different one; flat → hold, gather more data.\n3. **At/above goal** → leave the value-aware default (don't overspend).\n\nThe router reads the niche policy (`aiForJob($job, ['niche'=>...])`) and escalates\naccordingly. `dz goal` reports fleet average, at-goal count, and per-niche standing\nworst-first — the niches the ratchet is working on.\n\n**First reading (July 2026):** fleet 80.3, goal 85, 12/46 at goal. Weakest niche:\n**restaurant (74, accuracy 67)** — high fact density (menus/prices/specials) is\nwhere content invents things. Escalated. Real-estate (84/acc 88) and non-profits\n(84) are already near goal. The single biggest accuracy lever is reading the real\nwebsite: prefer the **free Jina source-of-truth cache** over the paid, deprecated\nFirecrawl `deep_research` (v0 API) — same job, better accuracy, no cost. Retiring\nFirecrawl for enrichment is the recommended consolidation.\n\n\nThis is doctrine, not a setting. It encodes how much care goes into a Doozer\nsite: the accuracy, the taste, the scoring, the deliberate balance of cost and\nquality — and the deliberate refusal to become dependent on any one AI house.\nDoozer builds because. It does not waste time, and it does not waste money — but\nit never trades away real value to save pennies.\n\nCode: `includes/ai-job-router.php` (the engine), `includes/ai-models.php` (the\nmodel catalog + tiers + cost map), `includes/site-scoring.php` (the feedback).\nOperator surface: `dz aimodels`, `dz aimodels pool=1`.\n\n## The principle (Jon's words)\n\n> \"There is no reason to use a cheap model for even a slight loss in quality if\n> the value is still hundreds of times the cost.\"\n\nSo the question is never \"what is cheapest.\" It is: **is the quality I would give\nup by going cheaper worth more than the money I would save?** If yes, GO HARD.\n\n## Three tiers, one framing\n\n| Tier | Framing | Anthropic | OpenAI | xAI | Google |\n|---|---|---|---|---|---|\n| flagship | **GO HARD** | Opus 4.8 | GPT-5 | Grok 4 | Gemini 2.5 Pro |\n| balanced | **Solid A** | Sonnet 5 | GPT-5 mini | Grok 4 Fast | Gemini 2.5 Flash |\n| budget | **Economy** | Haiku 4.5 | GPT-5 nano | Grok 3 mini | Gemini Flash-Lite |\n\nThe tier resolves to the **current best model in that class** via\n`aiModelResolve()` — so when a new model ships, we update the catalog in one place\nand every job that asked for a class is upgraded. Pin a concrete id only to freeze\na chore to a known model.\n\n## The math (the sweet spot, per output)\n\nEvery AI function declares four things (`aiJobRegistry()`):\n\n- **floor** — the lowest tier it may ever use (a quality floor).\n- **value** — the dollars one *good* output is worth.\n- **spread** — how much output quality *actually* varies flagship→budget for this\n  job. A label is a label (spread ≈ 0.02: a cheap model nails it). Bespoke site\n  copy is not (spread ≈ 0.15: the creative gap is real and worth paying for).\n  Difficulty, not just value, decides.\n- **typical tokens** — for the cost estimate.\n\nThe engine walks candidates best-quality-first (flagship → floor) and steps down\nto a cheaper model **only when**:\n\n```\nΔquality × value   ≤   Δcost\n```\n\nThe dollar value of the quality you'd lose vs the dollars you'd save. It stops at\nthe first step where quality is worth more than the savings — that is the sweet\nspot. For a high-value output (a site that is a claimable lead), the pennies of\ncost delta never outweigh the quality, so it **goes hard**. For instrumentation\n(scoring) or trivia (classification), the cheap model is genuinely good enough.\n\nQuality per tier starts from a difficulty-aware prior\n(`1 − spread × tierPenalty`, penalty: flagship 0, balanced 0.5, budget 1.0) and is\nthen **blended toward measured evidence** as real scores accumulate.\n\n## What the engine currently decides\n\n- **GO HARD (Opus 4.8)** — content, content_flagship (hero/visitor builds),\n  design/Muse, strategy, oracle, enrichment, marketing, conversation, vision.\n  These touch the customer or feed accuracy; their quality is worth far more than\n  the cost delta.\n- **Solid A (Sonnet 5)** — scoring. It is internal instrumentation: quality-\n  insensitive and high-volume, so Solid A is the sweet spot.\n- **Economy (Haiku 4.5)** — summary, extraction, classification. Trivial, high-\n  volume; a cheap model is as good, and at volume the cost is what matters.\n\nSee it live: `dz aimodels` prints job → choice → model → value → est cost → why.\n\n## The learning loop (it gets smarter)\n\nPriors are a starting belief, not the last word. Every scored site feeds\n`aiRecordJobQuality(job, provider, model, score)` into `ai_model_quality`, so the\nengine learns the **real** quality each model produces for each job — the score\nfrom one model vs another, over real Doozer sites. Content generation records\nwhich model wrote a site (`businesses.custom_settings.content_model`); when the\n[six-goal scorer](site-scoring.md) rates that site, the score is credited to the\nmodel that wrote it. Over time the decision sharpens from evidence, not assumption.\n\n## Diversity — never platform-dependent\n\nA small share of eligible calls (≈10%) is routed to an **alternate provider of the\nsame tier**. This is deliberate: it keeps quality estimates fresh across houses,\nkeeps every vendor warm and callable, and means Doozer is never hostage to one\nAI company's pricing, availability, or policy. If one house stumbles, the work\nkeeps flowing. Diversity is resilience, and it is also honesty about the fact that\ndifferent models genuinely produce different results for the same prompt.\n\n## Overrides (they win)\n\nThe algorithm is the default, not a cage. Per call: `['hard' => true]` forces\nflagship, `['tier' => ...]` / `['provider' => ...]` pin a choice. Per job:\n`platform_settings.ai_job_tier.<job>` and `ai_job_provider.<job>`\n(`dz aimodels set job=<j> tier=<t> provider=<p>`). Only **callable** models\n(API key present) are ever chosen, so it just works as keys come and go.\n\n## Provider notes / gotchas\n\n- **Thinking-capable models** (Sonnet 5; adaptive-thinking Opus) return a\n  `thinking` content block *first* — code must scan `content[]` for the text block,\n  never assume `content[0]`. Both `callAnthropic` and `generateWithClaude` do.\n- **OpenAI GPT-5 / o-series are reasoning models** — they spend a large, invisible\n  share of the completion budget on reasoning before the answer, so JSON/structured\n  tasks need generous `max_completion_tokens` (the router path adds headroom).\n- **Google Gemini** routes via its OpenAI-compatible endpoint; needs a `google`\n  api_keys row to be callable.\n\n## Still to wire (follow-ups)\n\n- The flexible content path (`generateContentFlexible`) should set\n  `__lastContentModel` too, so the learning loop captures every content route, not\n  just `generateWithClaude`.\n- Migrate the remaining hard-pinned call sites to `aiForJob()`:\n  `muse-family-generator`, `muse-recompute-palette`, `ai-classifier`,\n  `anthropic-chat`.\n- A per-job monthly budget governor for very high-volume jobs.\n\n## Why this matters\n\nA Doozer site is built with care: real source material read and fact-checked, six\ngoals measured, taste enforced. The model behind that work is chosen with the same\ncare — the best tool for each job, paid for when the value is there, saved when it\nis not, learned from what it produced, and never locked to one vendor. That is how\nwe create value without wasting time or money."
        },
        {
            "id": "billing",
            "title": "Billing",
            "type": "System",
            "group": "systems",
            "description": "Product-agnostic Stripe billing — one connection sells everything, entitlements flow to lb_users.tier.",
            "tags": [
                "billing",
                "stripe",
                "monetization"
            ],
            "path": "systems/billing.md",
            "links": [
                "site-creation"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Billing\n\nOne Stripe connection sells everything (Premium now; add-ons, domain-connect later).\n`includes/billing.php` is the product-agnostic core.\n\n- `billing_products` (SKU catalog; kind sub|one_time; `grants` entitlement; per-mode\n  price ids), `billing_orders` (ledger), `billing_subscriptions`.\n- Secrets are ENCRYPTED in `platform_settings` (not `api_keys`, not env). No SDK —\n  a curl client. Checkout for both kinds; HMAC webhook to `billingGrantEntitlement()`\n  (the same grant path as founders codes).\n- Entitlement = `lb_users.tier`. Cancel only downgrades `premium_source='stripe'`.\n- Cost protection: clouds (directories over existing sites) are unlimited; creating\n  BRAND-NEW sites is metered per user per month (`user_site_creations` +\n  `platform_settings billing.limit_new_sites_pm`). See [site creation](../processes/site-creation.md).\n- Self-serve via Stripe Customer Portal (`billingCreatePortalSession()`).\n\nAdmin: the Revenue nav group (`admin-billing.php` + `admin-users.php`). Adding a product\nis a Stripe Price + a catalog row, no new plumbing."
        },
        {
            "id": "cloud-headers",
            "title": "Cloud Header Images",
            "type": "System",
            "group": "systems",
            "description": "Every cloud (and root) gets a header. Real image > Keeper opt-out gradient > a designed per-cloud placeholder. AI generation is the upgrade path.",
            "tags": [
                "clouds",
                "design",
                "images",
                "ai"
            ],
            "path": "systems/cloud-headers.md",
            "links": [
                "cloud",
                "index"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Cloud Header Images\n\nA [Cloud](../glossary/cloud.md) should never read as blank just because its Keeper has\nnot yet uploaded or generated a header. As of v2.2.197 every cloud has a header by\ndefault. Engine: `includes/cloud-header-ai.php`.\n\n## Precedence (`cloudHeaderTopCss($img,$src,$name,$c1,$c2)`)\n\n1. **Real image** (`header_image_url` set) → the image with a gentle legibility overlay.\n2. **Keeper opt-out** (`header_image_source = 'none'`) → a plain brand-color gradient.\n   This is a deliberate manual choice (a \"No header image\" checkbox in the cloud editor),\n   never the default.\n3. **Otherwise** → a designed, per-cloud **placeholder** (the default).\n\n## The placeholder (free, instant, distinct)\n\n`cloudHeaderPlaceholderSvg($name,$c1,$c2)` builds an SVG from the cloud's own brand colors:\na diagonal gradient, a soft light bloom, a seeded ring motif (deterministic per cloud via\n`crc32(name)` so each differs), and a faint serif monogram of the cloud's first letter.\nServed as a data URI (`cloudHeaderPlaceholderDataUri()`), so it costs nothing and renders\nimmediately. Used on the [Discover Clouds](../index.md) cards (`includes/discover-clouds.php`).\n\n## AI headers (the upgrade)\n\n`cloudGenerateHeader($cloudId,$provider)` composes a header from the cloud's vibe (type,\nniches, place, colors), never a literal image, via GPT Image or Gemini Nano Banana 2\n(v2.2.11). Per-cloud \"Generate AI header\" lives in the cloud editor; a batch\n\"Generate missing headers\" (Admin → Clouds) backfills clouds with none, capped per run\nbecause each call costs an image credit (operator-triggered, respecting\n[API-cost discipline](../index.md)). The placeholder fills the gap until an AI or uploaded\nimage lands.\n\nDecision: headers are on by default (placeholder), opting out is manual, real images are\nthe goal. Taste first, no blanks, no surprise spend.\n\n## The flowing-cloud movement engine (v2.2.516)\n\nDistinct from the header IMAGE above: the animated cloud LAYER over the hero\ngradient. `includes/cloud-texture.php` → `cloudTextureLayer()` (drop-in on\ncloud-directory, discover-clouds, cloud-create, me-cloud). A WebGL fragment\nshader — fractal noise (fbm) + domain warping, animated by time — so the clouds\ngenuinely FLOW and morph (not a panning texture). Inspired by OpenAI's GPT-Live\nhero backdrop, rebuilt from scratch.\n\nEvery look is a uniform, driven from the my.doozer.cloud vibe controls:\n- **Mood** (`--c1/--c2`) → cloud colour; call `window.dzClouds.refreshPalette()`.\n- **Energy** (0..4) → `window.dzClouds.setEnergy(n)` (speed + vividness).\n- **Storm** (new slider, Clear→Tempest) → `window.dzClouds.set({storm})` ramps\n  warp amplitude, turbulence, speed, and contrast.\n- Also `set({speed,vivid,tintAmt})`; all persist to localStorage.\n\nResponsible: DPR capped 1.75, `low-power`, paused when the tab is hidden or the\nheader is off-screen (IntersectionObserver). Graceful fallback: no WebGL or\n`prefers-reduced-motion` → the original two-layer SVG fractalNoise texture\n(`cloudTextureSvgUri`), kept intact. Self-contained, no CDN."
        },
        {
            "id": "clouds",
            "title": "Clouds (curated directories)",
            "type": "System",
            "group": "systems",
            "description": "The cloud engine — how niche/region/criteria directories are built, accessed, and personalized, including the Cloud Factory and my.doozer.cloud.",
            "tags": [
                "clouds",
                "directory",
                "curation",
                "cloud-factory",
                "doozer-cloud"
            ],
            "path": "systems/clouds.md",
            "links": [
                "cloud",
                "architecture",
                "directory-framework",
                "shared-business-card",
                "cloud-headers",
                "prospector",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Clouds\n\nA [Cloud](../glossary/cloud.md) is a curated directory of businesses grouped by\nniche, region, or arbitrary criteria. Clouds sit on the same\n[engine](architecture.md) as roots and NBMN, rendered through the shared\n[multi-view framework](directory-framework.md) and [business card](shared-business-card.md).\n\n## The engine\n\n`includes/cloud-engine.php`. Clouds use `is_active` (NOT `status`). Membership is a\n**filter** (states / cities / geo bounds), not a fixed radius, so large areas work.\nManual curation can include/exclude specific businesses (admin Members panel).\n`doozer.cloud` apex is the public **Cloud of Clouds** (Discover Clouds). Each cloud\ncarries an AI-generated [vibe header](cloud-headers.md).\n\n## Access model\n\nPublic (AUTO or CLOSED join), Unlisted, or Private, so a cloud can be an open\nregional directory, a curated collection, or a members-only list.\n\n## Cloud Factory\n\n`includes/admin-cloud-factory.php` (Decide → Build → Manage): give it a niche +\nregion and it creates the cloud, runs a [Prospector](prospector.md) sweep, and\nbatch-builds the discovered businesses (which auto-join). Geo areas reuse\n`googleGeocodeSearch` bounds. This is how a themed directory goes from idea to\npopulated in one flow.\n\n## Personal — my.doozer.cloud\n\nEach signed-in user gets a personal cloud (`includes/cloud-directory.php` personal\nbranch) with a **Vibe System**: Mood (color) x Energy (0-4) preference; headlines\nfollow energy via `meCloudHeadlineBank()`; reduced-motion keeps colors. This is the\nconsumer-facing, \"your neighborhood, your way\" surface. Saved businesses derive\ntheir URL from the business's ACTIVE domain, never a stored `lb_follows.domain_id`.\n\nClouds turn the flat fleet into infinite meaningful cross-sections, each a new\ndiscovery surface feeding [the flywheel](the-flywheel.md)."
        },
        {
            "id": "content-generation",
            "title": "Content Generation Pipeline (providers, models, timeouts)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "ai",
                "content",
                "providers",
                "reliability",
                "seo"
            ],
            "path": "systems/content-generation.md",
            "links": [
                "content-generation-and-heal",
                "location-data-enrichment",
                "design-standards",
                "dev-api",
                "hosting-infrastructure"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Content Generation Pipeline\n\nThis is the engine behind [Content Generation and Healing](../processes/content-generation-and-heal.md).\nIt produces `businesses.ai_generated_content` (headline, about, services, why_choose_us, faq,\nmeta_description, suggested_colors). Hard-won operational truths live here — read before touching\nAI content, model config, or regeneration.\n\nThe governing principle (why site voice varies so much place to place) lives in\n[Per-Location Data Enrichment](location-data-enrichment.md): a site's voice is signal-in,\nsignal-out. The lever for a thin site is more/better input data, never a forced tone.\n\n## Entry point + the TWO paths (keep both in sync)\n\n`generateBusinessContent($businessRow)` in `includes/ai-content.php` is the entry. It takes the\n**business row** (from `getBusinessById()`), NOT a Google-Places shape. There is **no**\n`generateContent()` — calling that name silently broke every content regen with \"Content generator\nunavailable\" (fixed v2.2.235).\n\nIt tries two paths, and a change to ANY of prompt / timeout / model must cover BOTH:\n\n1. **Flexible path (tried FIRST, used in production):** `generateContentFlexible()` in\n   `includes/ai-provider-system.php`. Reads the per-content-type model config, builds the prompt\n   from the DB template, calls `callAnthropic()` / `callOpenAICompatible()`.\n2. **Legacy path (fallback):** `ai-content.php`'s own `callAnthropic`/`callOpenAI`. Has its own\n   model resolution + the historical default `claude-3-5-sonnet-20241022`.\n\nDURABLE RULE: the flexible path is what runs ~always. If you only patch `ai-content.php` you patched\nthe wrong path. (This exact trap meant the anti-boilerplate footer didn't apply for a full cycle —\nsee below.)\n\n## Model config (settings, not code)\n\nPer-content-type provider/model lives in the **`ai_content_configs`** table (rows: `business_website`,\n`photo_vision`), joined to `ai_models` + `ai_providers`. Set it via **Admin → AI Management**\n(it's under the **Infrastructure** menu, `?page=admin&section=ai` → Content Configuration → Configure),\nNOT a migration. Class aliases resolve via `aiModelResolve()` over the `includes/ai-models.php`\ncatalog.\n\nDURABLE RULE: the content **primary model must be a FAST quality model — `claude-sonnet-4-6`.**\nDo NOT set a reasoning model (`gpt-5`, o-series) as the synchronous content primary: they take far\nlonger than the timeout for a 2048-token generation and every call aborts with HTTP 0. (gpt-5 as a\nprimary is what caused the June 2026 network-wide content outage.)\n\n## Timeout (why 90s)\n\nContent asks for up to **2048 output tokens**; a quality model needs **25-60s** for that length.\nThe historical **30s** cURL timeout aborted generation mid-stream → cURL returns **HTTP 0**. Fixed:\n`AI_TIMEOUT_SECONDS = 90` in `config/app.php` (legacy path) AND `callAnthropic`/`callOpenAICompatible`\nin ai-provider-system.php default their cURL timeout to `AI_TIMEOUT_SECONDS` (v2.2.238). Both paths,\nor it regresses. Safe because content gen runs via the queue worker (post-response) or CLI batches,\nand admin synchronous regen is rare/one-at-a-time — not a visitor FPM hot path.\n\n## Anti-boilerplate (the SEO-load-bearing part)\n\n`_aiContentProseQualityFooter()` (ai-content.php) prepends the shared\n[Design Standards](design-standards.md) rules + an ORIGINALITY block that BANS templated openers\n(\"nestled\", \"in the heart of\", \"stands as a beacon\", \"more than just a\", \"steeped in tradition\") and\nem-dashes, and requires a page-specific opener built from real data. It is appended in BOTH paths\n(legacy always; flexible since v2.2.239). Without it, similar businesses get near-identical prose —\nthe near-duplicate cluster that triggered the [elks.club scaled-content demotion]. See the\n`project_seo_cliff_recovery` memory + [site-quality scoring](dev-api.md) for measuring boilerplate.\n\n## Failure modes (decode these fast)\n\n- **HTTP 0** — cURL timeout (slow model + tight timeout) OR connection failure. If `dz ai_ping`\n  shows providers 200 in <1s, it's the model/timeout, not the network.\n- **HTTP 400 \"Your credit balance is too low\"** — the provider account is out of prepaid credits.\n  This stalled a batch mid-run (~1,158 calls drained Anthropic). It IS logged (callAnthropic logs\n  4xx) but to error_logs in **UTC** while batch logs print `America/New_York` — a ~4h offset that\n  makes it look unlogged. Check instantly with **`dz ai_status`** (flags billing/credit/auth).\n- **\"Content generation returned nothing\"** — `generateBusinessContent` returned null (both paths\n  failed). The `regenerationRun` result carries a `detail` / `$GLOBALS['__lastContentGenError']`\n  with the real per-provider cause; surface that, don't guess.\n\n## Regeneration + batch rollout\n\n`regenerationRun($businessId, 'content')` ([regeneration.php]) wraps `generateBusinessContent` with a\nreadability quality gate (keeps prior content if the new draft fails) + manifest stamp. Fleet rollout\n= worst-first over [site-quality-score](dev-api.md) boilerplate-flagged pages, rate-limited, resumable\n(re-run skips now-clean). ALWAYS `dz ai_status` first — a big batch on an empty credit balance fails\nsilently. Operator preference: drive batches via the [Dev API](dev-api.md), not raw `php -r` pasted\ninto a root shell.\n\n## Diagnostics\n\n- `dz ai_status` — live provider health + billing/credit alert + recent provider errors (UTC-correct).\n- `dz ai_ping` — minimal keyed probes to both providers (egress vs auth vs timeout).\n- `dz quality root=<root>` — boilerplate/flag rollup to measure de-boilerplate progress.\n\nSee also [hosting infrastructure](hosting-infrastructure.md) (the queue + drain) and\n[design standards](design-standards.md) (the prose rules enforced at the source)."
        },
        {
            "id": "context-city-poisoning",
            "title": "Context-city poisoning (never name a place after where you found it)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "geo-anchor",
                "accuracy",
                "urls",
                "par-create",
                "prospector",
                "lesson"
            ],
            "path": "systems/context-city-poisoning.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Context-city poisoning\n\n**The bug shape:** a creation path knows two cities — the one it is *looking from* and the\none the place is *in* — and uses the wrong one. It happened twice, independently, in two\nsubsystems written months apart. Assume it will happen a third time.\n\n| Path | Used | Should have used |\n|---|---|---|\n| `parCreateSkeleton()` | the ANCHOR business's city (the page the visitor was on) | the place's own address |\n| Prospector discovery | the SWEEP CENTER's city (`$resolvedCity = $center['city']`) | the place's own address |\n\nResults in the wild: a Brooklyn bowling alley at `melody-lanes-morristown-nj.doozer.site`,\na Brentwood restaurant at `blaze-pizza-nashville-tn`, a New York restaurant at\n`tao-downtown-restaurant-jersey-city-nj`.\n\n## Why it is easy to miss\n\nThe context city is *almost* right. Most results of a Morristown sweep really are in\nMorristown, so the bug hides in the minority and every spot-check passes. It only shows up\nwhen you compare the slug against the address **systematically**. 239 of 3,439 domains\n(1,304 of which use the `city-state` slug shape) named the wrong town before anyone noticed.\n\nIt is also invisible in the product: the page content, title and schema were all correct,\nbecause those read `businesses.formatted_address`. Only the URL lied.\n\n## The rule\n\n> A city is a **fact about the place**, never about how you found it. Discovery context is\n> a hint for routing and a provenance note. It must never name anything.\n\nPar-create records this correctly for provenance: `custom_settings.discovered_on` keeps\nwhere a skeleton was seen, while `city` comes from the address. That is the right split.\n\n## The fix\n\n`parCreateCityStateFromAddress($addr)` in `par-create.php` parses the place's own city and\nstate from the address we already hold, and returns `['','']` rather than guessing when it\ncannot parse confidently, so callers fall back rather than invent. Both paths call it now.\n\nWatch the address shapes: Google returns both `\"912 Red River St, Austin, TX 78701\"` and\n`\"222 W 1st Ave, Plentywood, MT 59254, USA\"`. Strip a trailing country, then take the part\nbefore the `ST ZIP` tail. Reject a candidate that starts with a digit, which means you\ngrabbed a street line.\n\n## The audit\n\n`dz slugaudit` — scans every active domain, extracts a trailing `-city-state` slug, and\ncompares it to the address. Reports, dry-runs, repairs.\n\n**It splits the fix by risk, and that split is the important part:**\n\n- **not in the sitemap** → safe to re-slug. 217 were repaired this way.\n- **in the sitemap** → refuses unless forced, because renaming an indexed domain used to\n  be a deletion plus a creation. **Resolved in v2.2.541:** see below. All 22 are renamed\n  and the network now audits clean at 0.\n\n## Renaming a domain (the mechanism that was missing)\n\n`includes/domain-redirects.php`. A retired host keeps its `domains` row with\n`status='disabled'` (already means \"not serving\", so no migration) plus\n`custom_settings.redirect_to`, and `domainRetiredRedirect()` 301s it.\n\nTwo properties make it safe to leave switched on:\n\n- **Zero render-path cost.** The lookup runs only where a host has no ACTIVE domain row —\n  precisely where the configure wizard used to load. A live page never reaches it. Adding\n  a query to all 3,400 sites' hot path to serve a handful of retired hosts would have been\n  the wrong trade.\n- **Open-redirect guard.** The target must be https, carry no userinfo, and resolve to a\n  host we actually run. We are the only writer today, but a redirector that trusts a\n  database string is how you become somebody else's phishing hop later.\n\nRename procedure: UPDATE the live row's `domain` (keeping id, page-view history and\nsitemap membership), then INSERT a disabled row for the old host carrying the redirect.\nThen push the new URL to IndexNow so Bing-backed search sees the move.\n\n**A correction worth keeping.** The original justification for leaving indexed domains\nalone included \"the orphan falls into the configure flow and offers a stranger the chance\nto configure somebody's business.\" That was wrong: `runRootDomainGuard()` already returned\na 302 with `x-doozer-lockdown: blocked-subdomain` for unregistered subdomains. The real\nvalue of retirement is the **permanent 301** telling a search engine the address moved,\nnot protection that already existed. Verify the claim, not just the conclusion.\n\n## Transferable lesson\n\nWhen a naming bug is found in one subsystem, **grep for the same shape everywhere else\nbefore declaring it fixed.** The instinct that caught this was simply asking whether the\nbug had happened before. Both paths had it; only one was found by working on it directly.\n\nRelated: [[the-observed-world]], [[fact-guard-identity-numbers]], [[dev-api]]."
        },
        {
            "id": "design-standards",
            "title": "Design Standards and the Design Mirror",
            "type": "System",
            "group": "systems",
            "description": "One shared rule-set the generator, renderer, and admin self-diagnostic all consult — plus the mirror that scans and heals the fleet.",
            "tags": [
                "design",
                "quality",
                "self-diagnostic"
            ],
            "path": "systems/design-standards.md",
            "links": [
                "content-generation-and-heal",
                "no-dark-patterns-ethos",
                "muse-system"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Design Standards and the Design Mirror\n\nThe single source of truth for \"what a tasteful Doozer site looks like\" is\n`includes/design-standards.php`. It exists so the design layers stop patching the same\nflaw in isolation. Three layers consult it:\n\n1. **Generation** — the AI content prompt (`includes/ai-content.php`) injects\n   `designStandardsPromptRules()` so new content is born compliant (never bold a whole\n   paragraph; only short phrases; no em dashes; 2 to 3 paragraphs).\n2. **Render** — `includes/prose-render.php` delegates to `designStandardsTameInline()`\n   and `designStandardsStripEmDash()` as a safety net for non-compliant model output.\n3. **Diagnose** — the [Design Mirror](#the-mirror) scans the fleet and heals it.\n\n## Rules\n\n`designStandardsRules()` is an extensible registry. v1 rules: `over_emphasis`,\n`em_dash`, `wall_of_text`, `empty_content`. Adding a rule is one array entry.\n\n## The Mirror\n\n`includes/admin-design-mirror.php` (Admin → Design Mirror) scans live sites against the\nregistry, shows per-rule counts and percent-of-fleet with sample offenders, and offers\none-click remediation: **Heal** (reheal rules: re-saves cleaned content in place, zero\nAPI cost) and **Queue regeneration** (regen rules: enqueues content jobs). It never\nauto-acts; the operator controls spend.\n\n## The rule\n\nNew design-shape problems get a registry rule, NOT a one-off override. That is the\nanti-bandaid principle that motivated this system. See the\n[content generation and heal](../processes/content-generation-and-heal.md) process and\nthe [no dark patterns](../decisions/no-dark-patterns-ethos.md) ethos.\n\n## The regeneration readability gate\n\nThe [unified regeneration engine](../processes/content-generation-and-heal.md)\n(`includes/regeneration.php`) uses this registry as its safety net: after generating new\ncontent it scans, heals once, and rolls back to the prior content if the result is still\nbroken, so a regeneration can never leave a live site worse. What counts as \"broken\" is\nderived from the registry, not hardcoded: `designStandardsBlockingRuleIds()` returns the\nrules a heal cannot fix (`fixable = 'regen'`, currently `empty_content` and `wall_of_text`).\nCosmetic rules (`fixable = 'reheal'`, e.g. over-bold prose and em-dashes) are auto-healed\nand NEVER block. This encodes the operator principle: reject broken or unreadable output,\nnever reject a site for being bold or expressive. Any future `regen`-fixable rule joins the\ngate automatically.\n\n(History worth keeping: this gate shipped dead in v2.2.205 and was a silent no-op until\nv2.2.211 — it scanned for a severity vocabulary and scan shape the registry never produced.\nThe lesson, again: verify the wiring actually fires, do not assume it does.)\n\n## Scope: content shape, not visual CSS\n\nThis system governs the SHAPE of generated CONTENT (prose emphasis, em dashes, wall of\ntext, emptiness). Visual CSS safety, such as AA contrast and guaranteeing an outline\nbutton is actually visible, is a separate concern handled at render time by the\n[Muse System](muse-system.md)'s composition layers (`muse-contrast.php` plus the\nbutton-visibility safety in `muse-apply.php`). If a control is invisible or low-contrast\non live sites, look there, not in this registry."
        },
        {
            "id": "directory-framework",
            "title": "Directory Multi-View Framework",
            "type": "System",
            "group": "systems",
            "description": "The shared Grid / List / Map / Events view system that every directory surface (root, Cloud, NBMN) renders through.",
            "tags": [
                "directory",
                "views",
                "grid",
                "list",
                "map",
                "events",
                "framework"
            ],
            "path": "systems/directory-framework.md",
            "links": [
                "the-network",
                "clouds",
                "nearbymenow",
                "shared-business-card",
                "livemusictonight",
                "muse-system"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Directory Multi-View Framework\n\nOne framework renders every directory surface in the [network](the-network.md) —\nroots, [Clouds](clouds.md), [NearByMeNow](nearbymenow.md) — in multiple views\nwithout each surface reimplementing them. `includes/directory-views.php` owns the\nregistry and the JS contract; a view is toggled by an `html[data-dir-view]`\nattribute, and a surface opts into a view by rendering a\n`data-dir-view=\"<id>\"` panel and flipping it ready.\n\n## The views\n\n- **Grid** (live) — the default card wall, using the shared\n  [business card](shared-business-card.md).\n- **List** (live) — dense CSS rows with a subdomain/site pill.\n- **Map** (live) — a shared Leaflet engine fed by `?ajax=root_map_markers` /\n  `cloud_map_markers`; listings carry lat/lng.\n- **Events** (live) — upcoming events per business via\n  `businessUpcomingEventsBulk()` (`includes/business-events.php`), fed by\n  `root_events` / `cloud_events` ajax; vertical directories like\n  [Live Music Tonight](livemusictonight.md) render from `marquee_events`.\n\n## Distance sort\n\nRoot and Cloud both support distance sort (`resolveRootDomainOrder` /\n`cloudFetchListings` + a shared geo helper + `?e=geo`), so \"nearest first\" works\nanywhere.\n\n## Why one framework\n\nAdding a view once lights it up across every surface. The cost is the shared\ncontract's discipline: a new card action must be registered where each host routes\nits actions (e.g. NBMN routes its own card actions in `nearbymenow/index.php` — a\nnew action added only to the generic handler silently no-ops there). This is the\ndirectory analogue of the \"one renderer, many surfaces\" principle behind the\n[business card](shared-business-card.md) and [Muse system](muse-system.md)."
        },
        {
            "id": "atlas",
            "title": "Doozer Atlas (outward AI emission)",
            "type": "System",
            "group": "systems",
            "description": "The public, per-entity knowledge contract Doozer emits so external AIs can ingest the network — the outward-facing twin of the inward Brain.",
            "tags": [
                "atlas",
                "ai",
                "knowledge",
                "okf",
                "well-known",
                "outward"
            ],
            "path": "systems/atlas.md",
            "links": [
                "okf-adoption",
                "livemusictonight"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Doozer Atlas\n\nThe **Atlas** is Doozer's outward AI knowledge emission: a public, structured\nprojection of what the network knows, published so external AIs and agents can\ningest it. It is the mirror image of the [Brain](../decisions/okf-adoption.md) —\nBrain is inward (how Doozer understands itself, this bundle you are reading), Atlas\nis outward (how the world's AIs understand Doozer).\n\n## The contract\n\nEvery entity emits a public `/.well-known/doozer-atlas` document\n(`okfEntityFor*()` in `includes/okf-engine.php`) for its type: **Business**,\n**Cloud**, **Directory**, **Event**. The Atlas owns the CONTRACT — a derived\nprojection over live data. There is **no separate graph database**; the Atlas is\nmaterialized from the same tables that render the pages, so it can never drift from\nreality.\n\n## Why it exists\n\nSearch is shifting from links to answers. If an AI is going to answer \"what's a\ngood florist near West Milford,\" Doozer wants the network to be the citable,\nmachine-readable source, with accurate structured data (hours, geo, niche,\nreviews, events). The [JSON-LD on vertical directories](livemusictonight.md) is the\nsame instinct at the page level. This is a moat: a network that is legible to AIs,\nby design, is the one that gets cited.\n\n## Relationship to this bundle\n\nThis `knowledge/` OKF bundle (exportable as one portable `.zip` via Admin → Doozer\nBrain) is the Brain. The Atlas is the per-entity public feed. Both use the same OKF\nengine and the same \"it's just files / it's just structured data\" philosophy\n(see [OKF adoption](../decisions/okf-adoption.md)). Grow the Brain and the Atlas\ngets richer context to project."
        },
        {
            "id": "doozer-digest",
            "title": "Doozer Digest (AI daily newsletter)",
            "type": "System",
            "group": "systems",
            "description": "An AI-written daily email — a public reader edition and an operator admin edition — built from real platform signals.",
            "tags": [
                "digest",
                "newsletter",
                "email",
                "growth",
                "ai",
                "signals"
            ],
            "path": "systems/doozer-digest.md",
            "links": [
                "the-network",
                "admin-health-tools",
                "marketing",
                "the-value-model"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Doozer Digest\n\nAn AI-composed daily email, in two editions, each built from the same real\nplatform signals the rest of the network runs on (never invented).\n\n## Public edition\n\nA reader-facing daily digest (new sites, notable local businesses, events) that\nturns the growing [network](the-network.md) into a reason to come back. Shipped\npieces: the digest generator, an AI intro, and sponsor slots. Next: recipient\nmanagement / opt-in and the send cron.\n\n## Owner edition (per-business stats)\nA third edition for a CLAIMED business's owner: their numbers in their inbox\n(\"people are finding you\"). `includes/digest-owner.php` — `digestBusinessStats()`\naggregates views, calls, directions, website clicks, saves, and leads for a window\nplus the prior window (trend arrows) and lifetime reach, all from the same real\nsignals (page_views, click_events, lb_follows, nbmn_lead_requests, shortlinks).\nTwo tiers, one template: **Doozer+** gets all six metrics with trends; **claimed\n(free)** gets two headline numbers + an audience-aware upsell via\n`doozerPlusOfferFor()`. Tier resolves through the implicit-verification rule\n(admin_email authoritative, else scraped email -> owner's `lb_users.tier`). Preview\nwith `dz digest owner=<id> [tier=plus|free] [render=1]`. Built and previewable;\nNO send path is wired yet (recipient management + cadence are operator-approved).\n\n## Admin edition\n\nAn operator-facing daily brief: growth, revenue, key metrics, and an\n\"idea-of-the-day.\" It is the human-readable companion to the\n[admin health tools](admin-health-tools.md) — the same fleet supervised through a\nmorning email instead of a dashboard sweep. It shares the signal miners with\n[Marketing](marketing.md) (changelog, LeadBuilder metrics, recent discoveries), so\nthe numbers are the ones that actually run the business.\n\nBoth editions are downstream of the [value model](../decisions/the-value-model.md):\nthe public edition drives retention/discovery, the admin edition drives operator\nawareness. Roadmap order: NBMN demand-gen → Admin Digest → [Admin Alerts](admin-health-tools.md).\nSee [[project_doozer_digest]]."
        },
        {
            "id": "doozer-town",
            "title": "Doozer Town and the Board Game view",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "doozer-town",
                "clouds",
                "growth",
                "board",
                "groups",
                "geo",
                "self-building"
            ],
            "path": "systems/doozer-town.md",
            "links": [],
            "status": "stable",
            "generated": {
                "by": "claude-fable-5-1",
                "at": "2026-09-11"
            },
            "verified": [
                {
                    "by": "jon-berry",
                    "at": "2026-09-11"
                }
            ],
            "stale_after": "2027-03-01",
            "sources": [
                "places ledger",
                "clouds table",
                "job queue",
                "live pages"
            ],
            "body": "# Doozer Town and the Board Game view\n\n**The operator's two ideas (Sept 2026), which turned out to be one:** a Cloud view that\nturns any cloud into a Monopoly-style board with businesses as property cards grouped\ninto colour-coded categories; and `doozer.town`, dynamic self-building town clouds for\nevery town in every state. The grouping the board needed is the structure the town page\nneeded. Built in one pass as v2.2.615.\n\n## The address is the instruction\n\n`west-milford-nj.doozer.town` = West Milford, NJ. The slug parser accepts\n`<city-words>-<st>` only; the state must be real. Anything else is a 404, not a town.\n\nA visit resolves the slug in cost order (`townEnsure()` in `includes/town-engine.php`):\n\n| step | source | cost |\n|---|---|---|\n| existing town cloud | `clouds` row by host | $0 |\n| 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) |\n| 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 |\n\n`asdfgh-nj` returns `not_a_place`; a crawler on an unknown town gets a 404 \"not on the\nboard yet\" and spends nothing. A town is a normal `clouds` row (`cloud_type=geo`,\n`visibility=unlisted` so Discover Clouds stays curated) with a `town` block in\n`criteria` (city, state, county, slug, source, last count). It rides the cloud engine\nunchanged, so saves, counts and the card renderer all work. **A radius, not a city-name\nmatch**, is what makes \"serves West Milford\" true: Hewitt, Newfoundland, Oak Ridge,\nRingwood and Greenwood Lake sit inside it.\n\n## The ten groups\n\n`townGroups()` / `townGroupKeyFor()`: Eat & Drink, Shops, Home & Trades, Health,\nSelf-care, Auto & Around, Civic & Community, Money & Pros, Stay & Visit, Utilities &\nEssentials, plus Everything Else. Resolution order: explicit slug/type rules (because\n`businesses.primary_niche` mixes catalogue slugs and Google types, and a business's\n`types` JSON is consulted too), then the catalogue category, then keywords in the name.\nAn empty group is an **open lot** on the page and on the board and links straight to\nFind, because the brand's mark is a grid with one cell still open. West Milford after\nthe first pass: 355 businesses, 10 of 10 groups, 34 in Everything Else.\n\n## Money moves only on a tap\n\nRendering a town costs nothing. Find calls the existing `?e=nbmn_populate`\n(cache-first, bot gate, per-IP and global paid caps, the $10/day Places cap) around the\ntown centroid; Build posts to the existing `?e=nbmn_build_place` (hourly cap) and lands\nthe site on doozer.site, where the town's radius picks it up on the next render. The\ncounty eyebrow is one Magellan geocode per town, once, and only for a human.\n\n## The Board Game view\n\n`renderCloudBoardView()` in `includes/cloud-board-view.php`, `?view=board` on any\ncloud and on every town. Forty spaces: corners Start / Saved / Free parking / Build,\nnine per side. `boardDealSpaces()` deals a balanced hand: every non-empty group gets a\nspace, empties get an open lot, the rest is largest-remainder proportional, capped by\nsupply. More than 36 members lists the whole deck underneath (CSS columns, not a grid;\na grid left three empty columns beside one 66-item group). `?deal=n` reshuffles within\ngroups with the top third kept as anchors. Tapping a space opens a `<dialog>` deed with\nphoto, rating, address, call and visit. Pure renderer, spends nothing.\n\n## The edge (Plesk)\n\nEvery wildcard root is a `ServerAlias *.<root>` line in the subscription's additional\nApache directives (HTTP and HTTPS both). On the day it shipped `*.doozer.town` briefly\nanswered from another vhost until that line was in; `curl --resolve` against the origin\nIP is the only way to see past Cloudflare when that happens. With the alias live,\n`town_wildcard_live=1` makes the apex path form `doozer.town/<slug>/` a 301 to the real\naddress; the path form stays as a permanent link shape.\n\n## Operate it\n\n- `dz town slug=<city>-<st>` resolves (creating from inventory) and reports groups,\n  also-serves and nearby towns; `create=0` to look only; `force=1` to allow the\n  geocode path from the dev channel (its curl UA reads as a bot otherwise).\n- `dz town list=1 [state=NJ]`.\n- Telegram kind `town_created`.\n- Town pages are `noindex` under 5 businesses and on every filtered view.\n\n## The contract: a person arrives, the site builds\n\nEvery skeleton exists for free until a person opens it; that visit fetches the\ndetails (photos, website, phone, hours), writes the page, and the site is built by the\ntime they look around. The operator's rule (10 Sep 2026): **if a person is standing on a\nsite that has not built itself, that is a bug**, never a budget decision. Two\nconsequences. Paid backfills are not the way to fill boards; the visit is. And the\ncrawler checks that protect the budget must never catch a person: no bare IP-range\nguesses (a /16 for Cloudflare's renderer also matched iCloud Private Relay), only named\nagents, datacenter first octets, cached reverse DNS, and our own capture marker.\n\n## Pre-filling a town (the par-create method)\n\n`dz town prefill=1 slug=<slug>` is a dry run: for each group's niches it says whether the\nnearby-discover bucket is warm (somebody already paid for it: $0 skeletons) or cold (one\nNearby Search, $0.035). `live=1` creates skeletons from warm buckets; `budget_usd=2`\nlets it spend on cold ones, thin groups first. A full town is ~58 niches, about $2.\nSkeletons land on doozer.site (noindex), are enriched by their first human visitor\n(~$0.06 to $0.12), and appear on the board immediately through the radius. Pace it\ninside the Places hourly ceiling (one town an hour) and the daily cap.\n\n## What the first day cost, and why it will not again\n\n$10 in a day: $8.85 was an image crawler fetching uncached gallery photos on 284\nbusiness sites in four minutes, not the towns ($0.63). The photo proxy now refuses paid\nfetches for crawlers, the ledger has an hourly ceiling, and card photo sizes snap to the\ncached ladder. The town page itself renders for $0.\n\n## Where it sits in the ecosystem (Sept 2026)\n\nFour surfaces, one loop. **NearByMeNow** is search: what are you looking for.\n**Doozer Town** is place: everything that serves one town, ranked. **Business sites**\nare the entity: one page per place, the thing the other two point at. **Clouds** are\ncuration: a Keeper's chosen set. The loop is wired both ways: every business site's\n\"Around the neighborhood\" links to its town board, every board links to every site, the\nnetwork footer carries Doozer Town everywhere, and the apex sitemap (221 indexable\ntowns) goes to Bing by IndexNow. Bing is the channel; towns are the pages Bing can rank\nfor \"<town> businesses\" that a single business site never could.\n\nGrowth runs through the town: the pre-fill trickle feeds par-create from the biggest\nboards, skeletons appear on the board at $0, a person's first visit enriches one, the\nranking decides who leads a group, and sponsorship (a labelled slot above the ranked\nlist, never inside the score) is where the revenue lives when it comes. Honorary\nDoozers are the people who press Find and Build on a board they care about.\n\nEvery part of that spends only on a person, and every guard has a ledger line.\n\n## What is deliberately not done yet\n\nNo Atlas or llms.txt entry for towns yet (sitemap, robots, footer link and the\nsite-to-town link shipped 10 Sep); no town list beyond what our inventory implies (a Census gazetteer would let the apex list every\nplace in a state). The group rules will keep needing words added as Everything Else\nshows what the rules miss.\n\nRelated: [[clouds]](clouds.md) · [[the /now surface]](the-now-surface.md) ·\n[[growth engine]](growth-engine.md) · [[brand system]](brand-system.md) ·\n[[Magellan and the feed pond]](magellan-and-feeds.md)"
        },
        {
            "id": "email-tracking",
            "title": "Email Open Tracking",
            "type": "System",
            "group": "systems",
            "description": "A 1x1 pixel in every outbound email; opens recorded with device/geo/bot-flag; three operator alert signals plus data for the digest.",
            "tags": [
                "email",
                "tracking",
                "intent",
                "comms",
                "alerts",
                "analytics"
            ],
            "path": "systems/email-tracking.md",
            "links": [
                "doozer-digest"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Email Open Tracking\n\nEvery email `doozerSendEmail()` sends (the single mail chokepoint) gets a unique\n1x1 tracking pixel and an `email_sends` row. `includes/email-tracking.php`.\n\n## What is captured\n- **Pixel endpoint** `?e=eopen&t=<token>` (sessionless, returns a 1x1 gif, records\n  AFTER flushing so it never adds latency). `email_opens` rows carry device\n  (mobile/desktop), coarse geo (`CF-IPCountry` header when present), `ip_hash`,\n  and a **bot flag**.\n- **Bot / prefetch detection**: Apple Mail Privacy Protection, Google/Yahoo image\n  proxies, and any open within ~8s of send are flagged `is_bot=1` and kept out of\n  the alerts (still counted separately). `open_count` vs `human_open_count`.\n- **Age is computed in the DB** (`TIMESTAMPDIFF(SECOND, sent_at, NOW())`), never\n  from PHP `time()` vs a MySQL timestamp — that mismatch false-flags prefetches.\n\n## The three alert signals (📬 `email_open` ping)\n1. **Old email opened** (>= 14 days after send) — the headline: a dormant thread\n   reawakening means something is up. Alert-level, 6h re-throttle per send.\n2. **Unusual attention** — >= 4 human opens in 20 min. Hourly re-throttle.\n3. **High-signal first open** — first human open of an important kind\n   (`lead_forward`, `claim`, `owner_digest`, `lead`).\n\nAll opens are stored regardless, so the [Digest](doozer-digest.md) can report\nopens / devices / repeat-rate even when no alert fires. `dz emailtrack` for an\noverview; `dz emailtrack do=selftest [age_days=]` to exercise the flow.\n\n## Related\nTag an email with `['kind' => …, 'business_id' => …]` in the `doozerSendEmail`\nopts so alerts and the digest can attribute it. `['no_track' => true]` opts out.\nThe daily digest email to the operator is itself tracked (dogfooding).\nSee [[doozer-comms]] for the ping plumbing and [[notify-generously]] ethos."
        },
        {
            "id": "enrichment-store-links",
            "title": "Enrichment Store Links (commerce \"Shop Now\" CTAs)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "commerce",
                "store-links",
                "shop-now",
                "monetization",
                "confidence-gate"
            ],
            "path": "systems/enrichment-store-links.md",
            "links": [
                "location-data-enrichment",
                "the-value-model",
                "no-dark-patterns-ethos",
                "the-mission",
                "money-clouds"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Enrichment Store Links\n\nWhen [enrichment](location-data-enrichment.md) finds a business's real online store,\nthe site can surface a \"Shop Now\" CTA — a small commerce affordance and an\naffiliate/referral surface (see [the value model](../decisions/the-value-model.md)).\nThe hard part is not finding a link; it is not surfacing a BAD one.\n\n## The confidence gate (the load-bearing part)\n\nThe original extraction (`_extractExternalStores`) host-matched ANY marketplace\nlink, so roughly half the captured \"stores\" were not the business's own store at\nall — bare `amazon.com` homepages, search pages, \"recommended\" rails. Sending a\nvisitor there is a dead end and erodes trust ([no dark patterns](../decisions/no-dark-patterns-ethos.md),\n[the mission](../decisions/the-mission.md)). The fix: `storeLinkConfidence()` scores\na candidate, an admin audit panel surfaces low-confidence ones, and a **render-time\ngate** hides anything that does not clear the bar. A CTA only shows when we are\nconfident it is the business's real store.\n\n## Status\n\nThe confidence scoring + audit panel + render gate shipped; extraction hardening and\na backfill of already-captured links remain. This is the same discipline as the\n[store-link confidence in Money Clouds](money-clouds.md) affiliate feeds and the\n\"Shop Now\" on business sites: better to show nothing than to show a wrong link.\nSee [[project_enrichment_store_links]]."
        },
        {
            "id": "events-to-machines",
            "title": "Events to machines — a live event is only half published until a machine can read it",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "events",
                "schema-org",
                "ai-discoverability",
                "feeds",
                "gatherings",
                "timezone",
                "aeo"
            ],
            "path": "systems/events-to-machines.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Events to machines\n\n**The operator's goal, stated 2026-08-23:** Doozer sites should be *perfectly tuned for AI\nagents to find, understand and reference*, as well as for search engines and people.\n\nThat goal exposed a gap the day after events started working. Lodge and venue pages were\ncarrying real, current events — a visitor could read \"Friday Fish Fry, Aug 28, 4:00 PM\"\nperfectly well. The page emitted `LocalBusiness` and `FAQPage` and **no `Event` at all**.\nSo an AI agent, a search engine, or any structured-data consumer had no idea the event\nexisted.\n\n> **A live event is only half published until a machine can reference it.**\n\n## The pipeline\n\n    public feed  ->  bound by Magellan  ->  stored as facts  ->  rendered for humans\n                                                             ->  emitted as schema.org Event\n\nSources today (all keyless, all legitimate):\n\n| source | Magellan service | what it yields |\n|---|---|---|\n| public Google Calendar embed | `gcal` | full event feed with recurrence ([[magellan-and-feeds]]) |\n| Ticketmaster / Marquee | `ticketmaster` | ticketed shows at known venues |\n| schema.org on the business's own site | — | `Event` where present (rare: ~0 in sample) |\n\n`businessUpcomingEvents()` (`business-events.php`) is the single read path: gcal first\n(a business's own calendar outranks anything we infer), then Marquee. **One events\nsurface, many sources** — adding a feed must never add a second events UI.\n\n`buildEventsJsonLd()` (`schema-org.php`) emits from **the same call the page renders**, so\nthe structured data can never drift from what a human sees.\n\n## THE TIMEZONE RULE (this is the correctness problem, not a detail)\n\n`startDate` is only meaningful with a real UTC offset, and **a wrong offset is worse than\nnone — it moves the event.**\n\n- Rows that carry the event's own zone (`tz`, from the calendar) get a true offset:\n  `2026-08-23T13:00:00-07:00` for a San Diego lodge, generated on an Eastern server.\n- Rows with no zone emit a **local date-time with no offset** — valid ISO 8601, honest\n  about what we know. We do **not** stamp the server's zone onto a venue three time zones\n  away.\n- All-day events emit a date only.\n- Anything already past is dropped, never advertised.\n\nThis is the same failure that once displayed a San Diego lodge's Friday dinner as Saturday\nmidnight. It is not to be reintroduced through the structured-data path.\n\n## Where this is going\n\nEvery public gathering at a real place is a candidate for a **gohe.re Gathering** (QR,\nattendees, media, memories, chat, find-each-other map), and later a **wuz.ai** memory\nlayer. Both need each event to be an addressable, machine-readable entity first — which is\nwhat this node is the foundation for. Event categories worth ingesting as feeds appear:\nlive music, karaoke, trivia, bingo, tricky trays, fundraisers, bike nights, car shows,\ncruise nights, meetups, showtimes.\n\n**Facebook is not a source.** Page events cannot be read: direct fetch and mbasic both\nreturn HTTP 400, a reader returns a login wall, and the Graph API Page `/events` edge was\ndeprecated in 2018. Bind `facebook` for identity only, never scrape. Do not re-litigate.\n\nRelated: [[magellan-and-feeds]], [[event-feeds]], [[ai-discoverability]],\n[[gatherings-and-social]], [[the-observed-world]]."
        },
        {
            "id": "fact-guard",
            "title": "Fact-Guard (the accuracy net)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "fact-guard",
                "accuracy",
                "ai",
                "content",
                "healing",
                "quality"
            ],
            "path": "systems/fact-guard.md",
            "links": [
                "the-doozer-story",
                "content-generation",
                "elks-root-theme",
                "location-data-enrichment",
                "free-tier-render-gotchas"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Fact-Guard\n\nFact-Guard is a safety net for the one thing AI content must never get wrong:\n**verifiable facts.** A tasteful site that states the wrong lodge number or a\nmangled name is worse than a plain one — it breaks trust, which is the top of the\n[quality bar](../decisions/the-doozer-story.md).\n\n## What it does\n\n`factGuardHeal($content, $biz, $opts)` (`includes/fact-guard.php`) runs over\ngenerated content and conservatively corrects factual drift:\n\n- `factGuardDeriveIdentity($name)` parses a business name for a fraternal/civic\n  identity + number (Lodge / Post / Chapter / Council / Aerie / ... No. NNNN).\n- `factGuardFixIdentityNumbers($data, $keyword, $number)` repairs a wrong number\n  ONLY when a real separator is present (`No.` / `#` / `Number`), so it never\n  invents or over-corrects.\n\nIt is **conservative by design** — it fixes provable drift and leaves everything\nelse alone. It runs at two boundaries: at generation\n(`factGuardHeal(...)` after decode in [content generation](content-generation.md))\nand at the DB boundary (hooked into `saveGeneratedContent()`), so both fresh\nbuilds and re-saves are guarded.\n\n## Why it exists\n\nThe [Elks fleet](elks-root-theme.md) surfaced it: an AI given a lodge name would\noccasionally restate the lodge number wrong. Fact-Guard is the general pattern —\nwhere a fact is derivable from hard input (the name, the Places data), verify the\noutput against it. It complements the [GIGO principle](location-data-enrichment.md):\nGIGO says better input makes better voice; Fact-Guard says never let good input get\ncorrupted on the way out. Extend it as new verifiable fact classes appear.\n\n## Prevention beats correction: feed the facts into generation\n\nFact-Guard is the net, not the first line. The better fix for most inaccuracies is\nto hand the generator the fact so it never invents a wrong one — no per-fact trap.\nExample (v2.2.330): an AI hero claimed a barbershop was open \"Six Days\" while its\nhours showed closed Sunday AND Monday (open 5). Root cause: the content-generation\ncontext (`ai-content.php`) fed name/address/rating/reviews but NOT the hours. Fix:\ninject the real `weekday_text` plus a computed \"Open N days a week (closed …)\" line.\nThe model can't invent a schedule it's been handed. Operator rule: prefer accuracy\nby GIVING the model the fact over trapping for a specific mistake — same spirit as\nthe [muse custom_css prompt fix](free-tier-render-gotchas.md). Fact-Guard stays for\nthe classes a prompt can't reliably prevent (e.g. an identity number the model\nmangles even when told)."
        },
        {
            "id": "fact-guard-identity-numbers",
            "title": "Fact-guard — org identity numbers, and the hole a conservative regex left",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "accuracy",
                "fact-guard",
                "elks",
                "content-generation",
                "trust"
            ],
            "path": "systems/fact-guard-identity-numbers.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Fact-guard — org identity numbers\n\n`includes/fact-guard.php` is the network accuracy net: it overwrites AI drift on facts we\nhold canonically. Rule 1 is the **organization identity number** (Elks/Moose \"Lodge\",\nVFW/American Legion \"Post\", Eagles \"Aerie\", K of C \"Council\"). Two hooks, zero API cost:\na render-layer heal in `business-site.php` and a DB-boundary heal in\n`saveGeneratedContent()`. Repair tool: `dz factguard` (audit / per-business decontaminate /\n`heal=stored`).\n\n## The lesson: a conservative regex can no-op silently and still look right\n\nShipped v2.2.318, the corrector required a `No.` / `Number` / `#` separator between the\nkeyword and the digits, deliberately, so ordinary prose (\"post 2020 renovation\") could never\nbe mangled. It was correct and it was tested. But the hallucination in the wild is uniformly\nthe **bare** form: *\"Elks Lodge 339 in Plentywood\"*. The heal matched nothing, changed\nnothing, threw nothing, and read as working for two months (operator note #18, July 20 2026).\n\nTwo things compounded it:\n\n- Both `factGuardHeal()` call sites passed **no `$opts`**, so the number was derived from\n  the business NAME. Many lodges are scraped as plain \"Elks Lodge\" or \"Elk's Club\" with no\n  number at all, so the heal was a guaranteed no-op for exactly the sites that needed it.\n- The generator was never told the number. `businesses` has **no domain column** and both\n  generation entry points load the row with a bare `SELECT *`, so the subdomain — the one\n  identity fact we MINT rather than scrape — was out of scope in `buildBusinessContext()`.\n  Meanwhile the prose brief asks for \"a concrete fact: founding/charter year\". The prompt\n  rewarded inventing precisely the thing nothing supplied.\n\n**Takeaway: a healer that cannot report what it changed is indistinguishable from a healer\nthat does nothing.** `dz factguard heal=stored` now emits a real before/after per row.\n\n## The harder lesson: over-correction is worse than the original bug\n\nThe first cut of the bare-form rule rewrote every lodge number in the content to ours. On\nlive pages that produced:\n\n- `Freemasons of Newark Lodge #83` → `#1249` (a Masonic lodge sharing the building)\n- `a party of six from Lodge 401` → `Lodge 1249`\n- `close relationship with Dallas Elks Lodge 71` → `Dallas Elks Lodge 2485`\n\nEach trades one wrong fact for a **false claim about a different real organization**.\nEnumerating \"foreign cues\" kept losing, so the rule was inverted: only correct a mention\nthat is positively OURS. A mention is skipped when preceded by a foreign-order or\nother-lodge cue, by `from`, or by a **place name that is not one of our own city tokens**.\n\n> Leaving another lodge's number intact is not a false claim about us. Changing it is a\n> false claim about them. The tie always breaks toward leaving it.\n\nThe cost of that conservatism is real and accepted: a handful of pages simply never state\ntheir own number in prose. That is a gap, not a lie.\n\n## Two traps worth remembering\n\n- **Compare numerically.** The Order zero-pads: `?LodgeNumber=0031` is lodge 31. A string\n  compare flagged ~130 correct rows as contaminated and would have rewritten them all.\n- **Compare decoded structures, not JSON.** Re-encoding `ai_generated_content` differs in\n  slash/unicode escaping even when nothing changed, which reported ~170 phantom rewrites.\n- **Single-digit charter numbers are real** (Louisville #8, St. Louis #9). A `\\d{2,6}` floor\n  silently skipped them.\n\n## The root cause was DATA, not tone\n\nPlentywood (business 1658) had the wrong lodge in its own record: `website` pointing at\n`LodgeNumber=339`, `email` `secretary@alleghenyelks.org`, a Pittsburgh `412` phone in\n`deep_research`. Enrichment scraped the wrong lodge; the generator faithfully wrote what it\nwas fed. Signal in, signal out. Seven elks businesses had a `LodgeNumber=` URL contradicting\ntheir subdomain, two of which still got the right number in content, so the bad-website\nproblem is slightly wider than the bad-content one.\n\n**That leaked ownership, which mattered more than the copy.** With `admin_email` NULL, the\nscraped `email` IS the implicit owner ([[project_implicit_verification]]), so an officer of\nthe Pittsburgh lodge could have signed in by magic link and managed a Montana lodge's site.\nThree businesses were in that state. A scrape of the wrong entity is untrustworthy\nfield-by-field, so decontamination drops the derived scrape wholesale\n(`deep_research.contact_email`, `additional_phones`, enrichment provenance) and re-points\n`website` at the lodge's own canonical page. Google's geo-verified phone and address are\nnever touched, and an email naming our own lodge or city is KEPT.\n\nVerification method that settled every case: fetch `elks.org/lodges/home.cfm?LodgeNumber=N`\nand compare its city/ZIP to the business's `formatted_address`. All seven subdomains proved\ncorrect at ZIP level; the stored websites were the wrong ones.\n\n## The number is the least of what a wrong-lodge scrape carries\n\nAfter the websites were corrected, Flora IL still linked to **Carmichael CA's\nFacebook** and rendered **Carmichael's photo as its hero**, because the first pass\ncleared four keys and left `social_media`, `images`, `logo_url`. Camillus NY had a\ncorrect website but only **Chicago lodge 2367's** images. Wrong photo and wrong\nsocial link are worse than a wrong number, and neither is reachable by a text healer.\n\nDetection took three tries; the failures are the useful part:\n\n- A loose `~elks(\\d+)~` read Facebook page ids and **image dimensions**\n  (`WoburnStonehamElks-150x150.png` → \"150\") as charter numbers, condemning 5 clean rows.\n- Scoping to elks.org URLs still flagged lodges that legitimately embed another\n  lodge's asset (a state president's photo, a shared bingo graphic).\n- **The rule that holds:** a foreign lodge number in an elks.org asset path is damning\n  only when **none** of the assets are ours.\n- `json_encode` writes `\\/`, so a char class stopping at the backslash truncates the\n  URL to `www.elks.org` and hides the path carrying the number. Un-escape first.\n\n`purge=1` forces the purge for a hand-verified case, because repairing the website\nfirst removes the signal the detector keys on.\n\n## Adjacent defect, deliberately not fixed here\n\nTwo businesses (685, 1601) carry a **spurious second elks domain** whose lodge number\ndiffers from the one their content correctly states. That is a domain-mapping problem, not\nan accuracy one; `dz domdupes disable=<domain_id>` is the tool. `dz factguard` refuses any\nbusiness whose elks domains disagree, so it cannot \"fix\" one of these by corrupting good data.\n\nSee also: [[elks-root-theme]], [[content-generation]], [[source-of-truth-cache]], [[dev-api]]."
        },
        {
            "id": "gatherings-and-social",
            "title": "Gatherings + Social (events, check-ins, the human layer)",
            "type": "System",
            "group": "systems",
            "description": "The roadmap surfaces that add real-time human signal — events/gatherings (GoHe.re), check-ins (wuz.ai), and how they enrich every business site.",
            "tags": [
                "gatherings",
                "events",
                "checkins",
                "gohere",
                "wuzai",
                "social",
                "roadmap"
            ],
            "path": "systems/gatherings-and-social.md",
            "links": [
                "location-data-enrichment",
                "livemusictonight",
                "directory-framework",
                "user-hotspots",
                "the-network",
                "clouds",
                "atlas",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Gatherings + Social\n\nThe strongest content lever for a business site is not better prose — it is **real\nhuman signal**: what is happening there, who was there, what they thought. This is\nthe [enrichment](location-data-enrichment.md) direction made social, and mostly\nroadmap (some live).\n\n## Events (live pieces)\n\n`marquee_events` (Ticketmaster via `marquee-engine.php`) powers vertical\ndirectories like [Live Music Tonight](livemusictonight.md) and the Events view in\nthe [directory framework](directory-framework.md). An `lmt_interest` demand graph\nrecords view / interested / ticket-click signals, feeding an admin \"Live Music\nDemand\" build loop: real demand decides which venue sites to build next.\n\n## GoHe.re — gatherings (roadmap)\n\nThe events/gatherings root. The **Events Waterfall** strategy builds backwards:\nticketed events → venue sites → calendars → Global Gatherings. \"Instant\nGatherings\" and \"Wuz-for-One\" let anyone create a gathering for any place + time.\nThis is the surface where user-supplied events, comments, and photos flow into the\n**true voice** of each location.\n\n## Check-ins — wuz.ai (roadmap, flaky-live)\n\nCheck-in / \"Wuz\" toolbar signals who is where, now. `user_hotspots`\n([user hotspots](user-hotspots.md)) already derive a coarse home centroid for\nsigned-in users to enable proximity features.\n\n## Why it matters\n\nPer the [GIGO principle](location-data-enrichment.md), a site sounds like itself\nonly when fed real signal. Events, check-ins, and gathering photos/comments are\nthat signal, and crucially they feed **every** surface on the shared\n[data spine](the-network.md) — the business site, the [Clouds](clouds.md), the\nvertical directories, and the [Atlas](atlas.md) emission. One person RSVPing to a\nshow enriches the venue's site, its directory listing, and what AIs know about it.\nThat is [the flywheel](the-flywheel.md) reaching into the physical world."
        },
        {
            "id": "doozer-prospects",
            "title": "Hot Leads — the internal Doozer-sales CRM",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "prospects",
                "hot-leads",
                "crm",
                "doozer-plus",
                "awareness",
                "monetization",
                "leadbuilder",
                "machi"
            ],
            "path": "systems/doozer-prospects.md",
            "links": [
                "ai-postcards"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Hot Leads — the internal Doozer-sales CRM\n\n`includes/doozer-prospects.php`. Shipped v2.2.416-417. The strategic payoff of all the\ntracking: it turns postcards + claims + the leads-we-deliver into a ranked list of who is\nwarm enough to close on Doozer+. Jon's flywheel, made visible: they know us → we send them\nvalue (leads) → we make the right offer → free becomes Doozer+.\n\n## The awareness score (conversion readiness, 0-100)\n\n`businessDoozerAwareness($bizIdOrRow)` → score + component breakdown + status. Weights live in\n`doozerAwarenessWeights()` (one place to tune):\n\n| signal | pts | why |\n|---|---|---|\n| claimed the listing (`admin_email` set) | +40 | they acted, they know us — biggest |\n| scanned our postcard (`thanksio_events` scan) | +18 | engaged with our mail |\n| postcard delivered | +6 | mail physically reached them |\n| each postcard sent (`thanksio_sends`) | +3 (cap 9) | touches |\n| **each lead WE delivered** (`nbmn_lead_requests.routed_to_business_id`) | +12 (cap 40) | we give value — the strongest \"we have something to offer\" |\n| live Doozer site | +4 | inventory exists |\n\nStatus: paying owner (`businessIsPremiumOwned`) → `customer` (not a lead — a save/expand\ntarget). Else `hot` (≥55) / `warm` (≥25) / `cold`.\n\n## Hot Leads list + admin\n\n`doozerHotLeads($opts)` — candidate set = businesses we've TOUCHED (mailed OR delivered a lead\nOR claimed); never-touched businesses are not leads. Scored, ranked hottest first, customers\nexcluded by default. `$opts`: niche, state, limit, min_score, include_customers. Admin at\n**Intelligence → Hot Leads (Doozer+)** (`admin-prospects.php`): score bars, the \"why\"\nbreakdown, and Postcard / Manage quick actions. CLI `dz prospects [do=hot|score b=]`.\n\n## Next best action + channel (v2.2.418)\n\n`doozerProspectAction($row)` returns action + channel + urgency (now|soon|later) + reason.\nThe channel rule (Jon): getting leads + email → **email** the claim invite; getting leads +\n**NO email** → **mail a postcard STAT** (these sort to the top — leads with no inbox = money on\nthe table); mailed already + still no email → **call** the phone; claimed + free → Doozer+ pitch\n(email else mail). **SMS is OUT (regulatory).** Facebook Messenger via official page = future.\n`doozerHotLeads` sorts urgency-first. `doozerProjectedClaim($row)` = projected claim % (heuristic\nuntil real outcomes; getting-leads ×3.5, scanned ×2 over a ~4% cold baseline) — sharpens as\nclaim/convert rates by niche+market accrue (feed from the postcard Format-performance panel).\n\n## The LeadBuilder pipe (v2.2.418)\n\n`doozerProspectsPipeToLeadBuilder()` (admin \"Sync to LeadBuilder\"; `dz prospects do=pipe`)\nupserts each prospect into **`lb_leads`** (in the LeadBuilder DB via `nbmnLeadGetDb`), source\n`doozer_internal`, intent `doozer_plus_prospect`, under a synthetic \"Doozer Internal Sales\"\n`lb_site`, one `lb_visitor` per business (reuses `nbmnLeadEnsureLbSite`/`Visitor`). `score_factors`\ncarries `doozer_business_id`, awareness, projected %, signals, and next_action/channel/urgency.\nIdempotent (upsert by site+visitor+source). Internal prospects now live inside LeadBuilder proper.\n\n**Distinct from Machi** (`machi.md`): Machi = OUTBOUND NEED (\"who to mail next\"); this =\nCONVERSION READINESS + the exact next move. NEXT: campaign/niche/market response comparison to\nauto-weight targeting (\"mechanics claim 20% more → mail more mechanics\"); FB Messenger channel;\ncross-sell Keeper/agency to `customer`-status businesses.\n\nTies to [[project_monetization_plan]], [[the-observed-world]] CLAIM→EARN, the\n[[feedback_right_offer_right_audience]] rule, and [ai-postcards](ai-postcards.md) tracking."
        },
        {
            "id": "indexnow",
            "title": "IndexNow (instant crawl notification)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "seo",
                "indexnow",
                "bing",
                "crawl",
                "sitemap",
                "quality-gate",
                "indexing"
            ],
            "path": "systems/indexnow.md",
            "links": [
                "seo-cliff-recovery",
                "the-observed-world"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# IndexNow (instant crawl notification)\n\nIndexNow is a free \"push\" protocol: you notify participating search engines\n(Bing, Yandex, Seznam, Naver) that a URL is new or changed, and they crawl on\ndemand instead of waiting for a sitemap re-fetch. **Google does not consume\nIndexNow**, so this is a Bing-first play, and a deliberate hedge: elks.club\ncliffed on Google's scaled-content classifier\n([SEO cliff & recovery](../decisions/seo-cliff-recovery.md)), so diversifying the\ntraffic base onto Bing matters.\n\nShipped v2.2.359. `includes/indexnow.php`.\n\n## How ownership works across 2,400+ domains\n\nIndexNow verifies you own a URL by fetching a key file (`https://<host>/<key>.txt`)\n**on the same host as each submitted URL**. Doozer already serves `robots.txt`\ndynamically on every domain, so we do the same for the key file: `index.php`\nmatches `/<32-hex>.txt` and echoes the one shared key. That means a single key\n(`platform_settings.indexnow.key`, generated on first use) verifies the entire\nnetwork, with no per-domain key management. The route is on the sessionless path\nlist so it never takes a session lock.\n\nBecause each Doozer business site is its own host (e.g. `lodge.elks.club`),\nsubmissions are one host per request. Directory/root hosts with many URLs batch\ninto a single request (IndexNow allows one host, many URLs).\n\n## The load-bearing guardrail\n\nSubmit **only index-eligible URLs** — the exact sitemap rule:\n`domains.status='active' AND include_in_sitemap=1 AND\nbusinesses.ai_generated_content IS NOT NULL`. Never push skeletons, `noindex`, or\nthin pages. Pushing slop to a search engine re-teaches it that we emit slop,\nwhich is the precise mistake that hurt elks. This reuses the existing quality\nsignal rather than inventing a new one — enrich-then-index, same as the\n[observed-world flywheel](../decisions/the-observed-world.md).\n\n## Triggers (both async, via the `indexnow_submit` job)\n\n- **Content change:** `saveGeneratedContent()` (ai-content.php) enqueues after any\n  write that leaves a business index-eligible. Covers fresh builds crossing the\n  gate AND every regen (so a de-boilerplate fleet run doubles as a Bing recrawl\n  signal, for free).\n- **Manual include:** the admin \"toggle sitemap\" handler enqueues when a domain is\n  flipped INTO the sitemap. Removal is a no-op (IndexNow has no de-index).\n\n## Safety rails\n\nDaily cap (`indexnow.daily_cap`, default 2000), short-window dedup (don't\nre-hammer a URL pushed OK in the last hour), a self-creating `indexnow_submissions`\naudit table, and loud logging on any 4xx/5xx (the elks credit-outage lesson:\nnever swallow a provider error). Zero API cost.\n\n## Operating it (Dev API)\n\n`dz indexnow` — status (key, enabled, cap, sent today, recent rows).\n`dz indexnow test=<url>` — force one submit (bypasses the switch) to prove the\npipe. `dz indexnow enable=1|0` — master switch. `dz indexnow rotate=1` —\nregenerate the key (invalidates the old key file). `dz indexnow run=backfill\nroot=<r>` — queue every currently-eligible URL for a root, chunked (e.g. backfill\nelks.club to push the ~1,700 de-boilerplated pages at once).\n\nVerify with Bing Webmaster Tools → IndexNow, which shows submissions landing."
        },
        {
            "id": "lead-capture",
            "title": "Lead Capture (the revenue pipeline)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "leads",
                "monetization",
                "contact-form",
                "leadbuilder",
                "routing",
                "value"
            ],
            "path": "systems/lead-capture.md",
            "links": [
                "the-value-model",
                "implicit-verification",
                "free-tier-render-gotchas"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Lead Capture\n\nLeads are how Doozer turns free sites into revenue (see\n[the value model](../decisions/the-value-model.md)). There is ONE lead pipeline;\nevery surface feeds it.\n\n## The pipeline\n\n`nbmn_lead_requests` is the single table. A lead enters from:\n- **A business site's contact form** — `includes/contact-form.php`\n  (`contactFormButton()` + `renderContactFormDialog()`), posting to\n  `?e=nbmn_lead_request` with `lead_source=business_site` + `selected_business_id`.\n- **A NearByMeNow directory CTA** — the \"have a top-rated <niche> reach out\" form.\n- **Call / intent signals** — `includes/call-intent-categories.php`\n  (`callIntentChipsForNiche()`) logs to `lead_intents` + bridges to LeadBuilder.\n\nFrom the table: **admin triage** (`includes/nbmn-lead-requests.php`) →\n**route-to-owner** (`nbmnLeadRouteToBusiness()`) → **LeadBuilder bridge**\n(`leadCaptureBridgeToLeadBuilder()`). Routing to an owner emails them when a\ncontact address is on file.\n\n## One business, one inbox (the canonical recipient)\n\n`businessLeadRecipientEmail($business)` (nbmn-lead-requests.php) is THE single\nplace lead-recipient precedence is decided: the **claimed owner** (`admin_email`,\nset when someone redeems a postcard code or matches by email) wins; the scraped\n`businesses.email` is only the fallback for an unclaimed listing. One business,\none inbox — every lead from anywhere in the ecosystem (business site, NBMN, calls,\nclouds) goes to that one email. Future agency/agent fan-out extends this one\nfunction. The redeem + claim pages state the promise to the owner explicitly\n(\"the email you claim with becomes where every lead arrives\"). See\n[[identity-and-auth]] (implicit verification: `admin_email` = the claim).\n\n## Auto-route + the no-email plan (load-bearing)\n\nA `business_site` lead with a `selected_business_id` **auto-routes on submit** —\nthe target is unambiguous (it's the site's own business). If the business has\n**no email**, the lead is not dropped: Doozer fires a priority `basic_enrichment`\nemail-discovery scrape (the [implicit-verification](../decisions/implicit-verification.md)\nself-healing backfill) and flags the lead `awaiting_email` to deliver once an\naddress lands. **A lead is never a dead end** — delivered if reachable, hunted for\na channel if not, and held meanwhile as a warm lead + an ownership hook. NBMN\ndirectory leads still go through manual triage (which business a directory lead\nbelongs to is a judgment call).\n\n## Spam + integrity\n\nreCAPTCHA v3 (`includes/recaptcha.php`, invisible/score-based, **fail-open** so a\nGoogle outage never drops a real lead) + a honeypot field + per-IP-hash rate\nlimiting. Keys live in `platform_settings` (secret-free in the graph).\n\n## The gotcha that made it work\n\n`getParam()` (`includes/api-endpoints.php`) read ONLY `$_GET` until v2.2.319, so\nevery `?e=` WRITE endpoint silently ignored POST-body params and the lead forms\ncaptured nothing. It now reads `$_GET` then falls back to `$_POST`. Any new write\nendpoint can accept a normal form POST. Filed under\n[render gotchas](free-tier-render-gotchas.md): the write \"succeeded,\" the row just\nnever arrived."
        },
        {
            "id": "leadbuilder",
            "title": "LeadBuilder (the lead-intelligence platform)",
            "type": "System",
            "group": "systems",
            "description": "Where every captured lead, call intent, and cross-site referral flows to become revenue — the sibling product that turns Doozer traffic into qualified leads.",
            "tags": [
                "leadbuilder",
                "leads",
                "monetization",
                "intelligence",
                "tracking",
                "value"
            ],
            "path": "systems/leadbuilder.md",
            "links": [
                "lead-capture",
                "the-value-model",
                "shared-business-card",
                "the-network",
                "billing",
                "the-flywheel",
                "prospector"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# LeadBuilder\n\n`leadbuilder.io` is a BerrySmart sibling product: the **lead-intelligence\nplatform** that Doozer feeds. If [Lead Capture](lead-capture.md) is the plumbing,\nLeadBuilder is the destination and the intelligence layer — where a visitor's\naction on a business site becomes a qualified, attributable lead a business will\npay for. It is the primary way Doozer's traffic turns into revenue (see\n[the value model](../decisions/the-value-model.md)).\n\n## What flows in\n\nEvery demand signal on the network bridges to LeadBuilder:\n\n- **Leads** — contact-form and NBMN lead-requests, routed via\n  `nbmnLeadRouteToBusiness()` and piped in by `leadCaptureBridgeToLeadBuilder()`.\n- **Call intent** — the category-aware Call popups (`lead_intents`) bridge here.\n- **Visitor events** — `renderLeadBuilderSnippet()` mounts a lightweight tracker on\n  business + directory pages, recording sessions and events to the `lb_*` tables\n  (`lb_users`, `lb_sessions`, `lb_events`, `lb_follows`).\n- **Cross-site referrals** — the [neighborhood direct links](shared-business-card.md)\n  fire `sendBeacon` → the `track_network_referral` endpoint → a\n  `click_network_referral` `lb_event` on the SOURCE site, so movement across the\n  [network](the-network.md) is attributed, not lost.\n\n## Why it matters\n\nA free Doozer site creates presence; LeadBuilder captures the value that presence\ngenerates and makes it measurable and sellable. `lb_users.tier` also carries the\nentitlement that [Billing](billing.md) sells, so the same identity spans the free\nsite, the lead intelligence, and the paid tier. It is the revenue engine of\n[the flywheel](the-flywheel.md), and the reason \"we build a free site\" is a\nsustainable business rather than a cost center.\n\n## Google Search Console ingestion (the demand data)\n\nLeadBuilder also owns the network's **Google Search Console pipe** — the search\ndemand data that powers [Prospector](prospector.md)'s Cloud Radar and the\nmarketing signal miner. `includes/lb-gsc-client.php` is a dependency-free OAuth 2.0\n+ Search Analytics client. Operator connects GSC properties (Admin → LeadBuilder →\nGSC Properties); refresh tokens are stored encrypted in `lb_gsc_properties`.\n`lbGscSyncProperty($propertyId, $daysBack)` pulls query + page aggregates into\n`lb_gsc_query_daily` / `lb_gsc_page_daily`. GSC Search Analytics is **free** — no\nper-call cost.\n\n**Freshness is a background job.** `lbGscMaybeQueueRefresh()` runs from the\nindex.php shutdown tick (rate-limited 6h) and enqueues a per-property `gsc_refresh`\njob when a property's `last_synced_at` goes stale (>20h); the job dispatcher runs\nthe sync. Built v2.2.364 — before that the scheduler was referenced in a comment\nbut never existed, so ingestion silently died after one manual sync (May 2026) and\nCloud Radar ranked on a frozen snapshot.\n\n**Two durable gotchas (v2.2.364):** (1) refresh tokens are encrypted with\n`ENCRYPTION_KEY`; a key rotation breaks decryption unless the decrypt tries the\nlegacy keys — `lbGscDecryptTokenEx()` now uses `doozerLegacyEncryptionKeys()` +\nself-heals (re-encrypts with the current key). (2) If the Google OAuth **consent\nscreen is in \"Testing\" mode, refresh tokens expire after 7 days** → `invalid_grant`\n→ dead pipe. Fix: publish the consent screen to production + reconnect the\nproperties. Dev API `gsc` (status / `sync` / `scan`) drives + diagnoses it all."
        },
        {
            "id": "livemusictonight",
            "title": "Live Music Tonight (livemusictonight.com)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "vertical-directory",
                "events",
                "music",
                "marquee",
                "ticketmaster",
                "interest-graph",
                "prospector"
            ],
            "path": "systems/livemusictonight.md",
            "links": [
                "vertical-directory",
                "prospector",
                "cloud-factory",
                "the-flywheel",
                "shared-business-card",
                "gathering"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Live Music Tonight\n\n`livemusictonight.com` is a [Vertical Directory](vertical-directory.md) (one host row routed\nby `includes/vd-router.php` → `includes/vertical-directory.php`). It is not a bespoke page —\nit reuses the VD engine. It started empty because `vdFetchEvents()` read the unpopulated\n`dynamic_section_cache`; it now sources real shows.\n\n## Data resolution (here-and-now first)\n\nLive music is a NOW thing, so the homepage leads with **location + Tonight** and lets the\nvisitor expand outward in time and space.\n\n- **Located** (explicit `?lat&lng` from the geolocate button, or approximate from\n  Cloudflare `CF-IPLatitude/Longitude`): live Ticketmaster geo feed via\n  `marqueeTmFetch()` (gained `latlong`/`radius`/`unit`/`endDateTime`/`sort`), closest\n  first. If nothing falls in the radius/window, an empty state nudges widening — it never\n  silently dumps far-away national events on a located visitor.\n- **No location**: stored `marquee_events`, then a live national fallback so the page is\n  never blank. This is the browse mode where search/filter belongs (future).\n- Radius filter: 5 / 10 / 25 / 50 / 100 miles, **default 5**. Auto-requests precise\n  location on first load (remembers a denial via sessionStorage, no nagging).\n\n## Time windows (30-day horizon)\n\n`vdWindowEnd()` hard-caps everything at +30 days (no stray next-year listings). `Tonight`\nis here-and-now; `This Week` / `This Month` are calendar-bounded; `Next 7 Days` /\n`Next 30 Days` roll forward from today regardless of the date. The nav exposes the rolling\noptions via dropdowns on This Week / This Month.\n\n## The interest graph (the point)\n\nEvery **Interested** and **Tickets** click records a demand signal to `lmt_interest`\n(`?ajax=lmt_interest`, ip-hash rate-limited) keyed to **venue identity** + genre + area.\nThis is OUR derived demand signal, not a mirror of Ticketmaster's catalog (ToS-clean).\nAdmin → Marquee → \"Live Music Demand\" ranks venues by interest (interested×3 + ticket×5)\nand hands a venue to the build flow. So real demand decides which venue sites\n[Prospector](../glossary/prospector.md) and the [Cloud Factory](cloud-factory.md) build\nnext — the [flywheel](the-flywheel.md) applied to events.\n\n## Ecosystem ties\n\n- Cross-ecosystem identity: the [user avatar](shared-business-card.md) renders in the VD\n  header (magic-link sign-in), so Interested can tie to a real person.\n- Photos: TM event images render as card thumbnails (music-note SVG placeholder otherwise).\n- Icons follow the house rule — crafted inline SVGs, never emoji/clipart.\n- **Venue cross-link by ID:** Marquee stamps Ticketmaster's `tm_venue_id` onto the\n  `businesses` row when it builds a venue site (`marqueeStampVenueId`), so `_vdLinkVenues`\n  links a live event to its Doozer site deterministically (`businesses.tm_venue_id =\n  event.tm_venue_id`) instead of fuzzy name matching. This is the durable handle a venue's\n  own business site can use to point back at LMT or any vertical directory.\n- **Interested on business sites:** each event on a venue's Doozer site (rendered by\n  `renderBusinessEventsSection`) carries an Interested button (internal bookmark via\n  `lmt_interest`) beside Tickets and + Calendar — the network keeps the save instead of\n  pushing people to an external calendar.\n\n## Marquee is venue discovery, not an event log\n\nThe Marquee admin (`marqueeRecentVenues`) surfaces ONE row per distinct venue (keyed by\n`tm_venue_id`, else name+city+state), NEW venues (no Doozer site) first, built ones marked.\nThe product is *finding businesses to build* through the live event feed — not listing events.\nBuild acts on a representative event; the rest of the venue's shows become its site's calendar.\n\n- Future: an \"add all to calendar\" iCal feed on my.doozer.cloud for a user's saved events +\n  gatherings; clicking a show could spin up a [Gathering](../glossary/gathering.md) on GoHe.re\n  (gated on the gatherings surfaces); non-ticketed/community shows union into the same feed."
        },
        {
            "id": "magellan-and-feeds",
            "title": "Magellan and the feed pond — bound IDs turn sites into living entities",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "magellan",
                "feeds",
                "identity",
                "calendar",
                "elks",
                "accuracy",
                "strategy"
            ],
            "path": "systems/magellan-and-feeds.md",
            "links": [
                "magellan-id-mapping"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Magellan and the feed pond\n\n**The operator direction (Aug 21 2026):** \"Cross link as much as we can and turn feeds,\nsites, and other directories into data feeds. Doozer sites need to be living entities.\"\n\n## The spine (BUILT — `includes/magellan.php`, v2.2.549)\n\n`business_id_map (business_id, service, external_id, source, confidence, verified_at)`,\nunique per `(business_id, service)`, reverse-indexed on `(service, external_id)`. This is\nthe table specified in [magellan-id-mapping](../decisions/magellan-id-mapping.md) in June\n2026 and finally built. `magellanBind/Id/Find/All/BackfillHot/Stats`; `dz magellan`.\n\nLive coverage: **google 3,632 · elks_org 1,704 · gcal (growing) · ticketmaster 101.**\n\nThe hot columns (`businesses.google_place_id`, `businesses.tm_venue_id`) are NOT touched;\n`magellanBackfillHot()` copies them IN so both views agree while callers migrate.\n\n**The rule stays: join by ID, never by name+city+state.** Name matching guesses,\nduplicates, and fails silently.\n\n## Why a bound ID matters (the actual point)\n\nA generated page describing a business is one of thousands and gets skipped. A page\ncarrying **what is on tonight** is the only page answering that question. A bound ID is\nwhat makes the second thing possible, and it is also an accuracy engine: every feed is a\nsecond opinion on the facts.\n\n## Feed adapters\n\n### `gcal` — public Google Calendar (`includes/feed-gcal.php`, GENERIC)\n\nA public Google Calendar exposes iCal at a predictable URL with **no key and no quota**.\nAny page embedding one names a live, self-maintained event feed.\n\n    <iframe src=\"...calendar/embed?src=<id>\">\n      -> https://calendar.google.com/calendar/ical/<urlencoded id>/public/basic.ics\n\n`gcalIdsFromHtml` (handles the `addr@gmail.com` and hex `@group.calendar.google.com`\nforms, and `src` not being the first query param), `gcalFetchIcs`, `gcalParseUpcoming`,\n`gcalSyncBusiness`. RFC 5545: line unfolding, `VALUE=DATE`, `TZID`, `EXDATE`, cancelled\nevents, recurrence for `FREQ=DAILY/WEEKLY/MONTHLY/YEARLY` with `INTERVAL`/`BYDAY`/`COUNT`/\n`UNTIL`, hard-bounded. `BYSETPOS`/`BYWEEKNO` are NOT expanded (stated, not hidden).\n\n**GOTCHA — timezone is a correctness bug, not cosmetics.** Store the instant AND the\ncalendar's `tz`, and format with `gcalLocalString()`. First pass showed San Diego's\n\"Friday Dinner\" as Saturday 00:00 because correct UTC instants were formatted in the\nserver's zone. **Events belong to the place that holds them.**\n\n**GOTCHA — the reader strips iframes.** Calendar discovery must fetch RAW HTML\n(`elksOrgFetchHtml`), not `sourceFetchMarkdown()`. This is why the calendar page looked\nempty and was nearly written off as unusable.\n\n### `elks_org` — the BPOE registry (`includes/elks-lodge-contacts.php`)\n\nLodge number is in every elks.club subdomain (1,710/1,710), so the path is deterministic\nand needs **no search API key**. `elks.org/robots.txt` is permissive (only three history\nPDF paths disallowed). Per-lodge pages: `home` (address, meeting times, officers, official\nsite, Facebook), `lodgeFacilities` (social-quarter and hall hours — RICH, and exactly what\nthe real leads ask for), `lodgeNews`, `lodgeCalendar` (the Google embed), `contactUs`\n(form URL — surfaced for a HUMAN, **never auto-submitted**).\n\nMeasured on a 24-lodge random sample: **25% list their own website, 20% Facebook only,\n100% a verified address, 17% embed a calendar.**\n\n**GOTCHA — parse the BODY SLICE only**, between `## <City>, ST Lodge No. <N>` and\n`##### Navigation`. Outside it is elks.org chrome, which made the national vendor link\n`elksbenefits.com` look like a lodge website on the first pass (a MapQuest directions link\ndid the same). Officers are PROSE (\"The Exalted Ruler of the Lodge is X, and the Secretary\nis Y\"), not a labelled list.\n\n## Rules every adapter honours\n\n- **GEO-ANCHOR.** Feed addresses are REFERENCE only, never applied to the geo-verified\n  location. Enrich the content, never the coordinates.\n- **Facts, never prose.** Copying source prose is what caused the elks.club scaled-content\n  cliff. Extract data; let the site render it.\n- **No automated outbound.** Reading public pages is fine. Submitting a third party's\n  contact form at scale is not, whatever the volume argument.\n- **Dry-run by default**, `live=1` writes (the `dz harvest` convention).\n- **Be a polite guest**: delay between fetches, cache hard.\n\n## Next\n\nRender the bound data on the site (facilities hours, upcoming events). The facts are\ncaptured but the page does not use them yet — the same gap\n[[project_foundation_sprint]] notes for `source_facts`. Then more services: the lodge's\nown site, state associations, and other directories that publish structured member data.\n\nRelated: [[the-flywheel]], [[event-feeds]], [[money-clouds]], [[source-of-truth-cache]],\n[[project_where_the_traffic_is]]."
        },
        {
            "id": "marketing",
            "title": "Marketing (build in public)",
            "type": "System",
            "group": "systems",
            "description": "The composer that drafts social posts from real platform signals in the founder's voice, and the auto-draft review queue.",
            "tags": [
                "marketing",
                "build-in-public",
                "composer",
                "growth"
            ],
            "path": "systems/marketing.md",
            "links": [
                "the-doozer-story",
                "prospector",
                "the-flywheel",
                "atlas"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Marketing (Build in Public)\n\nDoozer markets itself the way it was built: **in public, from real signals, in the\nfounder's voice.** The composer drafts social posts (X) grounded in what actually\nhappened on the platform, never invented hype.\n\n## Signal miners\n\n`marketingMineChangelogVersions()` (parses `CHANGELOG.md`),\n`marketingMineLbMetrics()` (network reach, businesses indexed, hot leads, GSC\nimpressions, weekly new sites), and `marketingMineRecentDiscoveries()` (new\ndomains + businesses in the last 30 days) turn platform reality into post material.\nMetrics come from the same numbers that run the business, so posts are true.\n\n## Composer + voice\n\n`marketing_posts` holds drafts; `ai_prompts` rows (changelog / metric / discovery /\nfreeform) carry a @levelsio-style build-in-public voice with a **banned-phrase\nlist** baked in so drafts avoid AI-slop tells (and no em-dashes, per the\n[no-slop quality bar](../decisions/the-doozer-story.md)). Each request drafts 3\nvariants; the operator picks one.\n\n## Auto-draft loop (M4)\n\nWhen a [Prospector](prospector.md) discovery becomes a live Doozer site,\n`marketingDraftFromDiscovery()` fires automatically. All 3 variants persist as\ndraft rows tagged `auto_drafted: true`; the Marketing admin's Review queue groups\nthem by business, the operator picks one, siblings are trashed. So growth events\nproduce marketing material with no manual step, closing the\ndiscovery → build → announce loop from [Prospector](prospector.md).\n\nMarketing is the outward voice of [the flywheel](the-flywheel.md); the outward\n*data* voice is the [Atlas](atlas.md)."
        },
        {
            "id": "money-clouds",
            "title": "Money Clouds (affiliate vertical directories)",
            "type": "System",
            "group": "systems",
            "description": "Generalizing the Live Music Tonight pattern — any niche with an affiliate feed becomes a revenue-share vertical directory, bound across APIs by Magellan.",
            "tags": [
                "money-clouds",
                "affiliate",
                "monetization",
                "vertical-directory",
                "magellan",
                "roadmap"
            ],
            "path": "systems/money-clouds.md",
            "links": [
                "livemusictonight",
                "clouds",
                "directory-framework",
                "shared-business-card",
                "magellan-id-mapping",
                "prospector",
                "network-growth",
                "the-value-model",
                "billing",
                "gatherings-and-social"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Money Clouds\n\nThe affiliate-revenue generalization of the [Live Music Tonight](livemusictonight.md)\npattern (\"Make it Rain\"). LMT proved the shape: a vertical directory backfilled\nfrom a partner feed (Ticketmaster), rendered with structured data, monetized via an\naffiliate program. **Money Clouds** apply that shape to ANY niche that has an\naffiliate or revenue-share feed. Mostly roadmap; LMT is the live proof.\n\n## How it works\n\n- A niche + a partner feed → a vertical directory (like a themed [Cloud](clouds.md)),\n  backfilled from the feed and surfaced through the shared\n  [directory framework](directory-framework.md) + [business card](shared-business-card.md).\n- The place is bound across APIs by [Magellan](../decisions/magellan-id-mapping.md):\n  the Google place == the OpenTable place == the Hotels.com place, always by ID,\n  never name-guessing. That is what lets one search resolve, and one directory\n  backfill, from multiple paid feeds and monetize the same entity.\n- Demand (search + intent, e.g. the LMT `lmt_interest` graph) picks which verticals\n  and places to build, tying it to [Prospector](prospector.md) and\n  [network growth](network-growth.md).\n\n## Partner shape (verify ToS + program acceptance)\n\nFast breadth from **aggregator networks** (Travelpayouts, Skimlinks/Sovrn, Impact,\nCJ, Rakuten, Awin) plus a few **direct** integrations for depth and better rates\n(OpenTable, Booking.com/Expedia, Viator, SeatGeek, DoorDash). Each becomes a Money\nCloud. This is the revenue-diversifying arm of [the value model](../decisions/the-value-model.md)\nand [monetization](billing.md), beyond leads and premium. Sequence after the core\ninfra settles; see [[project_search_to_site_money_clouds]] and\n[the events waterfall](gatherings-and-social.md)."
        },
        {
            "id": "nearbymenow",
            "title": "NearByMeNow (the traffic + directory layer)",
            "type": "System",
            "group": "systems",
            "description": "The public \"what's near me\" surface that indexes the whole fleet, drives visitors back to business sites, and captures demand as leads.",
            "tags": [
                "nbmn",
                "directory",
                "traffic",
                "discovery",
                "referral-loop"
            ],
            "path": "systems/nearbymenow.md",
            "links": [
                "the-network",
                "architecture",
                "directory-framework",
                "shared-business-card",
                "lead-capture",
                "implicit-verification",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# NearByMeNow\n\n`nearbymenow.com` (NBMN) is the **active side of the referral loop** in\n[the network](the-network.md): a public directory that answers \"what's near me,\nopen now, and good,\" indexes every business in the fleet, and sends visitors to\neach business's own [generated site](architecture.md).\n\n## What it does\n\n- **Spatial discovery** — `nbmnFetchNearbyListings()` (`includes/nearbymenow-data.php`)\n  runs a Haversine + bounding-box query over `businesses.lat`/`lng` (backfilled by\n  `includes/geocode-backfill.php`) with niche/category filters. Cold-start city\n  searches use the `cities` centroid table.\n- **Visitor location** — `?e=geo` resolves location (browser → Cloudflare IP-geo\n  headers → none); `?e=businesses_near` serves geo-sorted JSON.\n- **Directory surfaces** — niche/category/city pages rendered through the shared\n  [multi-view framework](directory-framework.md) (Grid/List/Map/Events) using the\n  one [business card](shared-business-card.md).\n- **Demand capture** — every niche directory page carries a lead CTA that feeds the\n  shared [lead pipeline](lead-capture.md); call intent is logged per category.\n\n## Phase 2/3 features (shipped)\n\n- **Report-an-Issue** on every business site footer + a public\n  `?page=about-listings` explainer of the [implicit-verification](../decisions/implicit-verification.md)\n  model (the \"no account\" ownership ethos, with the `agencies@doozer.work` hook).\n- **Lead-request capture** (`nbmn_lead_requests`) with allowlisted fields, phone\n  digit validation, and per-IP-hash rate limiting.\n- **Self-healing email backfill** — `domain_visit_fingerprints` counts repeat\n  visits; a fingerprint seen enough times enqueues a harder email-discovery scrape,\n  so an owner who \"tried last week and it didn't work\" finds it works today.\n\n## Why it matters\n\nNBMN is what makes the fleet more than a pile of pages: it turns 2,400+ isolated\nsites into a searchable neighborhood, drives cross-site traffic (now via\n[direct neighborhood links](../systems/shared-business-card.md), no two-click\ndetour), and converts browsing into [leads](lead-capture.md). It is the traffic\nengine of [the flywheel](the-flywheel.md)."
        },
        {
            "id": "network-growth",
            "title": "Network Growth — \"More places nearby\" (build-on-tap, gated)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "growth",
                "more-places-nearby",
                "search-to-site",
                "gate",
                "prospector",
                "roots"
            ],
            "path": "systems/network-growth.md",
            "links": [
                "architecture",
                "magellan-id-mapping",
                "free-tier-render-gotchas",
                "the-flywheel",
                "nearbymenow"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Network Growth — \"More places nearby\"\n\nThe organic, demand-driven way the network indexes its next site. On every\n[business site](architecture.md) (below \"More from the neighborhood\", which shows\nEXISTING Doozer sites), a small section suggests up to 3 nearby same-niche places\nthat are NOT yet on Doozer, pulled from Google Places and deduped by place_id via\n[Magellan](../decisions/magellan-id-mapping.md). One tap builds that place's site\nand lands the visitor on it. A real visitor arriving from Google, with real local\nintent, spins up the neighbor that wasn't there yet. This is the same widget NBMN\nshows for thin category searches, brought to business sites.\n\n## The flow\n\n- Suggestions: `nbmnPlacesNotOnDoozer()` (`includes/nearbymenow-data.php`) — the only\n  paid Places call is a cache MISS (cached 30 days per niche + geo-bucket, empties\n  too). Rendered by `includes/places-nearby-suggest.php` (`renderPlacesNearbySuggest`),\n  `!important`-hardened chrome (muse pages).\n- Build: card click → `?e=nbmn_build_place` → `resolvePlaceToEcosystem(...)`\n  ([the WHERE engine](../decisions/magellan-id-mapping.md)). Skeleton-first, content\n  queued, noindex until quality-gated ([SEO discipline](free-tier-render-gotchas.md)).\n  Hourly throttle `platform_settings.nbmn_build_max_per_hour`.\n- Quality bar: `place_resolver_min_score` (default **40** — a 50+review / 4.3+ rating\n  local business is real content, not slop; it shouldn't fail just for lacking a\n  website, which is exactly who Doozer serves). Below-bar places DON'T build — the\n  card opens a \"contact anyway\" popup (name, rating, click-to-call, directions). The\n  internal score is NEVER shown to a visitor. Rejected place_ids (logged score < the\n  bar in `place_resolutions`) are dropped from future suggestions.\n- Self-dedup: a just-built place is re-checked against `businesses` at render so a\n  new site never suggests itself.\n\n## The two per-root guardrails (both opt-in, both in `root_domains.custom_settings`)\n\n1. **Service area** — a regional root's geographic reach: `service_area` =\n   `{radius_mi, optional lat/lng}` (center auto-derives from the root's business\n   centroid). `rootServiceArea()` (`geographic-helpers.php`). Filters suggestions AND\n   hard-gates the build, so westmilford.biz only ever suggests/builds within 12 mi.\n   **Distance is the limit, full stop (v2.2.394 fix).** Two bugs were closed:\n   (a) the build gate lived only on the unknown-place path, so a business ALREADY in\n   the ecosystem (e.g. a Paterson bakery, 18 mi out) skipped it and built a sibling\n   from \"More places nearby.\" The gate is now a shared helper\n   (`placeResolverServiceAreaBlock` in `place-resolver.php`) called on EVERY build\n   path, including reused/known businesses. (b) The suggestion filter's county\n   \"edge tiebreaker\" used to rescue any same-county place up to 2x the radius —\n   Paterson is the same county as West Milford, so it showed at 16 mi. Suggestions\n   are now strict to the radius too (any state in range, NO county rescue past it),\n   so the list agrees with the build gate and never teases an unbuildable site.\n   NOTE: the admin edit still uses raw lat/lng fields (place-picker is a TODO); the\n   values store correctly into `service_area`, so this was never the leak.\n2. **Expansion target** — WHERE a build lands: `expand_target` = `self` (a subdomain\n   on THIS root, open regional directories only) or `external` (**DEFAULT** — the\n   neutral doozer.site). `rootExpandTarget()`. The card's `data-root` carries the\n   resolved target. SAFE DEFAULT is external, so a curated/branded root\n   (elks.club, vsso.net) can NEVER sprout a random-business subdomain — those build\n   on doozer.site. westmilford.biz is set to `self`.\n\nBoth are managed per root in Admin → Root Domains. This is the intake valve of\n[the flywheel](the-flywheel.md), refined so growth is local, intent-driven, and\nnever pollutes a branded network. See [[project_search_to_site_money_clouds]] and\n[NearByMeNow](nearbymenow.md)."
        },
        {
            "id": "niche-matching-and-discovery",
            "title": "Niche matching and live discovery",
            "type": "system",
            "group": "systems",
            "description": "",
            "tags": [
                "nearbymenow",
                "search",
                "places",
                "niche",
                "discovery"
            ],
            "path": "systems/niche-matching-and-discovery.md",
            "links": [],
            "status": "current",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Niche matching and live discovery\n\nHow NearByMeNow decides \"does this business belong on the <niche> page?\" and\n\"what is actually near this person right now?\". Three bugs lived here at once,\nall invisible because each one only ever made results *smaller*, never wrong.\n\n## The rule: our tag OR Google's types\n\nA business carries `businesses.primary_niche` (our single-value guess) and\n`businesses.types` (Google's array). Niche pages matched only the first. That is\none guess per business against ~90 niches, so most businesses were unreachable\nfrom the page that describes them.\n\nMeasured 2026-08-23 on the live network:\n\n| niche page | matched by primary_niche | also tagged by Google |\n|---|---|---|\n| restaurants | 82 | +361 |\n| bars | 59 | +191 |\n| cafes | 9 | +56 |\n| hair salons | 14 | +19 |\n\nUse **`nbmnNicheMatchClause($nicheField, $typesField, $niche, $params, $db)`**,\nnever `nbmnNicheInClause` directly, for anything a visitor browses. It returns\n`(primary_niche IN (...) OR JSON_CONTAINS(types, ...) OR ...)`.\n\n**Two rules that keep it honest:**\n\n1. `establishment` and `point_of_interest` are excluded from the types side.\n   Google stamps them on 3,393 of 3,635 businesses, so including them makes\n   every niche universal.\n2. Exact `primary_niche` matches rank FIRST (`$nichePrecision` in\n   `nbmnFetchListings`). Google tags a bowling alley `restaurant` because it\n   serves food; that is a fine page-3 result and a bad page-1 result.\n\nMariaDB 10.6 - no `JSON_OVERLAPS`. Use OR'd `JSON_CONTAINS(f, JSON_QUOTE(:x), '$')`.\n\n## A niche slug is NOT a Google place type\n\n`hair-salons` is not `hair_salon`. `auto-repair` is not `car_repair`.\n`plumbers` is not `plumber`. `restaurants` is not `restaurant`.\n\nStripping punctuation out of a URL slug does not produce a place type, and\nGoogle silently ignores a type it does not recognise - the search still returns\n200 with generic nearby establishments, so the bug looks like \"discovery is\nmediocre\" rather than \"discovery was never asked the question\".\n\n**Always resolve through `niche_mappings`** via `nbmnNicheTypeValues($niche)`.\nWhen a niche maps to several types, prefer the one matching the slug's singular\n(`hair-salons` -> `hair_salon`, not the broader `hair_care`).\n\n## The thing you asked for is never junk\n\n`nbmnJunkTypes()` keeps the *untyped* \"who else is on this street\" widget free\nof ATMs, bus stops and gas stations. It contains `car_wash`, `dentist`,\n`doctor`, `pharmacy`, `hospital`, `bank`, `hotel`, `insurance_agency`,\n`real_estate_agency`, `storage`, `funeral_home`.\n\nIt was applied to explicit searches too. A visitor on the car-washes page\npressed \"Show what's near me\", got a correctly typed Google search for car\nwashes, and had every result discarded for being a car wash. Car washes and\ndentists returned zero cards, every time, for as long as the feature existed.\n\n**In `nbmnNearbyDiscover`, the junk filter runs only when `$type === ''`.** With\nan explicit type Google has already constrained the set; filtering again can\nonly subtract the answer.\n\n## Why all three hid for so long\n\nNone of them threw. Each one returned a smaller, plausible result set, and a\nshort list of local businesses looks exactly like a short list of local\nbusinesses. The symptom was recorded for months as \"thin inventory\" and \"dead-end\nniches\" - a data problem - when all three were query problems. **When a surface\nlooks under-stocked, check what the query is actually asking before concluding\nthe shelf is empty.**\n\n\n## The audit (2026-08-24) and what it added\n\nMethod: for every path a visitor can take to find a business, compute what the\nquery SHOULD return and compare to what it DOES. Four more defects, same\nsignature - all subtractive, none threw.\n\n### `niche_mappings.google_type` was never validated\n\nIt is the question every discovery call asks Google. Some values are not Google\ntypes. Google answers 400 INVALID_ARGUMENT, callers turn that into `[]`, and the\nniche reads \"nothing near you\" forever while billing for each attempt.\n\nRejections now land in **`places_bad_types`**; **`dz nichetypes`** reports them\nwith the niches affected, and `fix=1 live=1` applies renames. It applies ONLY\nrenames Google itself rejected - inventing a plausible type would just create a\nnew silent zero. `roofer` -> `roofing_contractor` took that niche from 0 results\nto 18.\n\n### A failed call cached its own emptiness for 120 days\n\nAn errored lookup and an empty neighbourhood both arrive as `[]`. Both were\ncached. One bad request could pin a niche to \"nothing here\" for four months\nafter the cause was fixed. `prDiscoveryLastCallFailed()` separates them; only a\nreal ZERO_RESULTS is remembered. **Never cache a failure as an answer.**\n\n### \"Best match\" and \"Nearest first\" were the same query\n\nThe route forced `sort=closest` whenever an origin was known. Two labels, one\norder. Best match is now a genuine blend - quality decayed by distance,\n`EXP(-d/8)` - which is what a person means by \"best\" in a local directory.\nRelated: the sort whitelist in the route silently dropped any value not in\n`[closest, rating, reviews]`, so adding a sort to the UI alone does nothing.\n\n### Structural findings (reported, not changed)\n\n- `include_in_sitemap` gates BOTH the sitemap and the directory listing query.\n  \"Do not submit this to Google\" and \"do not show this to a visitor browsing\n  the directory\" are different questions sharing one flag. 662 enriched\n  par-create skeletons are invisible because of it.\n- **57% of listable businesses appear on no niche page**, dominated by 1,330\n  `association_or_organization` - the lodges, which drive 68% of human traffic\n  and have no directory category at all. Venue types are unmapped too.\n\n## The rule this all points at\n\nEvery defect here returned a smaller, plausible answer. A short list of local\nbusinesses looks exactly like a short list of local businesses, so none of them\ncould be found by using the site - only by counting. **Any query that can return\nfewer results than it should needs a way to say so out loud**: record the\nrejection, flag the fallback, distinguish the error from the empty."
        },
        {
            "id": "location-data-enrichment",
            "title": "Per-Location Data Enrichment (the true voice of a place)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "ai",
                "content",
                "enrichment",
                "gatherings",
                "checkins",
                "events",
                "pipeline",
                "gigo"
            ],
            "path": "systems/location-data-enrichment.md",
            "links": [
                "content-generation",
                "free-tier-render-gotchas",
                "livemusictonight",
                "magellan-id-mapping",
                "cloud",
                "shared-business-card",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Per-Location Data Enrichment\n\nThe durable principle behind the [content generation pipeline](content-generation.md):\n**a site's voice is the sum of its input signal.** There is no house \"voice\" to author\nand no per-vertical \"voice\" to force. The generator distills whatever real signal a\nplace carries, so the quality of a site is the quality of its data. Signal in, signal\nout. (Operators sometimes say garbage in, garbage out; same law.)\n\n## Proof: the Elks lodges write themselves differently\n\nEvery `*.elks.club` lodge runs the identical pipeline and the identical bespoke\n[elks theme](free-tier-render-gotchas.md). Yet their heroes diverge completely, because\ntheir reviews and place data diverge (surveyed July 2026):\n\n- Golden CO #2740, West Milford NJ #2236 -> \"...Cold Beer...\" (their reviewers talk\n  about the bar; Golden is literally Coors country).\n- Lynnwood WA #2171 -> \"Brotherhood, Service, and Community Since Day One\".\n- Bloomington IN #446 -> \"120 Years of Brotherhood, Service, and Community\".\n- Alameda CA #1015 -> \"Alameda's Hidden Gem Since 1895\".\n- Summit County CO #2561 -> \"Brotherhood, Community, and Mountain Tradition\".\n\nAn Elks lodge is its own animal: some are inner-city halls, some are golf courses or\ncountry clubs. A lodge whose members and reviewers talk about veterans and scholarships\ngets a service-and-heritage site; a lodge that reads like a good neighborhood bar gets a\nbar site. That is correct behavior, not a defect. The lever for a thin site is never a\nforced tone. It is **more and better input signal.**\n\n## Where the richer signal is coming from (this is planned, not built)\n\nToday the dominant signal is scraped Google reviews plus place data. The roadmap adds\nfirst-party, location-owned signal, each of which sharpens the true voice of a place:\n\n- **Events** for physical locations, scraped / sourced / pulled (ties to the\n  [Live Music / marquee](livemusictonight.md) work and the events waterfall).\n- **Gatherings** at the location (GoHe.re) and their attendee-supplied **comments and\n  photos** — the warmest, truest signal a place can give off.\n- **Check-ins** (Wuz) — presence and rhythm: who shows up, when, how often.\n\nAs those land, the generator (and any re-enrichment pass) has real, current, member-\nsupplied material instead of only third-party reviews. A community lodge stops sounding\nlike a bar because the community finally speaks for it.\n\n## One input, many consumers: the Doozer Data pipeline\n\nLoad-bearing: **all data provided per location enriches more than that location's own\nsite.** A single enriched location record flows to:\n\n1. the **location site** itself (its prose, sections, and voice),\n2. the **database** (the canonical business record, keyed by identity, see the\n   [Magellan ID mapping](../decisions/magellan-id-mapping.md) spine), and\n3. every other surface that reads the pipeline: **vertical directories**\n   (e.g. livemusictonight.com), **[clouds](../glossary/cloud.md)**, the\n   [shared business card](shared-business-card.md), and the outward\n   [Atlas](the-flywheel.md) emission.\n\nSo enriching one place is never local. It compounds across the network. This is the\nenrichment half of [the flywheel](the-flywheel.md): better signal -> better site ->\nmore engagement (check-ins, gatherings, photos) -> more signal.\n\n## Practical consequence for content work\n\nBefore \"fixing\" a site's copy, ask what signal it lacks, not what tone to impose. The\nright move is usually to feed the location record (events, a gathering, real photos,\nbetter reviews), then regenerate, rather than to hand-author voice the data cannot\nsupport."
        },
        {
            "id": "user-hotspots",
            "title": "Per-User Location Hotspots",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "personalization",
                "geo",
                "privacy",
                "digest",
                "livemusictonight"
            ],
            "path": "systems/user-hotspots.md",
            "links": [
                "livemusictonight",
                "no-dark-patterns-ethos"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Per-User Location Hotspots\n\n`includes/user-hotspots.php` derives a signed-in user's **primary** and **secondary**\nlocation centroids (usually home and work) plus a typical **radius**, from the user's OWN\nsignals. It is a derived projection cached in `user_hotspots` (migration\n`sprint_user_hotspots.sql`), recomputed lazily — never a track log.\n\n## How it works\n\n- **Signals** (`userHotspotPoints`): check-ins (weight 3, the strongest \"you were here\"),\n  where the user searched on [Live Music Tonight](livemusictonight.md) (`lmt_interest`\n  area coords), and the coords of businesses they saved (`lb_follows`).\n- **Derivation** (`userComputeHotspots`): grid-bucket points at ~10 miles, rank by density,\n  take the top one or two clusters (the second only if >=15mi from the first) as centroids;\n  the typical radius is the 75th-percentile spread, snapped to 5/10/25/50/100.\n- **Read** (`userGetHotspots`): returns the cached row, recomputing if older than 7 days or\n  missing. `userForgetHotspots` clears it; `userHotspotLabel` names the nearest city.\n\n## What it powers\n\n- **Near-me defaults** — when a signed-in visitor has no live location (geolocation denied,\n  no Cloudflare geo header), LMT centers on the home hotspot and defaults the radius to the\n  user's typical distance. Precise geolocation still wins.\n- **my.doozer.cloud \"Your spots\"** — shown in plain language with a Forget control.\n- Future: reminder targeting (an interested artist returning within the user's radius) and\n  Digest \"near your home / near your work\" blocks.\n\n## Guardrail\n\nSigned-in only; derived from the user's own usage; coarse (centroids + radius, not a path);\nvisible and clearable on my.doozer.cloud. A convenience that remembers where you are, not\nsurveillance. See [no dark patterns](../decisions/no-dark-patterns-ethos.md)."
        },
        {
            "id": "postcard-targeting",
            "title": "Postcard targeting (the Postcard Queue)",
            "type": "System",
            "group": "systems",
            "description": "Who deserves a paid claim postcard — exclude committee-orgs and franchises, rank owner-operated marketing-spenders.",
            "tags": [
                "postcards",
                "claim",
                "targeting",
                "growth"
            ],
            "path": "systems/postcard-targeting.md",
            "links": [
                "source-of-truth-cache",
                "doozer-prospects",
                "claim-codes",
                "postcards",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Postcard targeting (the Postcard Queue)\n\nPostcards cost money, so we spend them on **owner-operated businesses that invest in their\npresence** (advertising / marketing / a website) and will act on a card by claiming.\n`includes/postcard-targeting.php`; tool `dz pcqueue state= city= limit= site=`.\n\n## The load-bearing part is the EXCLUSION\n\nData (2026-07): of ~2,657 live businesses, ~86% have a website, but **~65% are committee-led\norgs** (`association_or_organization` = 1,343, plus lodge/VFW/church/Rotary/township name\npatterns). A blind run would mostly hit Elks lodges. `postcardCandidates()` excludes:\n\n- **Committee/civic/worship/education/corporate-venue niches** (`postcardExcludedNiches`) —\n  associations, nonprofits, churches, government, schools, plus corporate venues (arenas,\n  theaters, bowling, malls) that came in via the Live-Music/Marquee side.\n- **Name patterns** (`postcardExcludeNameRegex`) — Lodge / VFW / Legion / Elks / Church /\n  Association / Township / \"Post 123\" …\n- **National franchises** (`postcardFranchiseRegex`) — corporate owns the web, they won't claim.\n- **Already-claimed** (`admin_email` set).\n\nExcluded orgs stay fully claimable (on-site nudge + email); they just don't burn a postcard.\n\n## The ranking\n\n`postcardNicheScore` weights marketing-spend propensity: high (3) = home services / food /\npersonal care / dental-medical / auto / legal-financial / fitness; mid (2) = general retail /\nservices / lodging; else 1. Score = niche×10 + has-website. Filter by geography (`state`,\n`cities[]`).\n\n## The batch-1 plan\n\nFirst ~100 postcards → owner-operated businesses in the **West Milford / North-NJ home\ncorridor** (verifiable in person, local word-of-mouth, highest conversion). NOT a\nsingle-niche run (per-niche pool too thin). Arizona strip complexes = batch 2 (cold market +\nstrip density; needs a Prospector discovery sweep). Archetype = Powell Flooring. See\n`docs/GOING_FORWARD.md` §1 for the exact `dz pcqueue` command.\n\n**Before mailing:** enrich each target site from its real website via the\n[source-of-truth cache](source-of-truth-cache.md) so a scanned card lands on an accurate,\nbetter page.\n\nSee also: [Hot-Leads CRM (doozer-prospects)](doozer-prospects.md),\n[claim codes](claim-codes.md), [postcards](postcards.md), [the flywheel](the-flywheel.md)."
        },
        {
            "id": "postcards",
            "title": "Postcards",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "story",
                "brand",
                "content",
                "admin"
            ],
            "path": "systems/postcards.md",
            "links": [
                "index",
                "okf-adoption",
                "the-doozer-story"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Postcards\n\nPostcards are how Doozer tells its own story, on purpose and over time. A Postcard\nis one dispatch about why Doozer exists, what it does, how, and for whom. It can be a\ntweet, a short note, an essay, or a quote. Admin lives at **Admin → Story → Postcards**\n(`includes/admin-postcards.php`); the engine is `includes/postcards.php`.\n\n## The shape\n\nSeven lanes, the journalist's six questions plus a vision lane:\nWHO / WHAT / WHEN / WHERE / WHY / HOW / VISION (`postcardDimensions()`).\nFour formats: tweet, short, essay, quote (`postcardFormats()`).\nAudiences: everyone, entrepreneurs, investors, operators, press, local customers,\nfor-the-record (`postcardAudiences()`).\n\n## Time and perspective (the reusable idea)\n\nEvery Postcard is written \"from where the story is today\" and stamped with the platform\nversion and date (`as_of_version`, `as_of_date`). As the platform changes, you write new\nPostcards; the old ones stay. The collection becomes the story's own changelog, readable\nfrom any moment. Evergreen cards can carry live tokens (`{site_count}`, `{cloud_count}`,\n`{version}`, `{year}`) so they always speak in today's numbers (`postcardFillTokens()`).\n\n## AI drafting\n\n`postcardAiDraft($dimension,$format,$audience,$voice,$brief)` drafts from live platform\nsignals (`postcardSignals()`) plus Doozer's ground-truth story facts, in a chosen voice.\nHard rules baked into the prompt: no em-dashes, no invented facts, speak from today,\nplain language, no AI-slop. Returns title + dek + body for the operator to review before\nsaving. Distinct from [Marketing](../index.md) build-in-public posts: Postcards are the\ndeep, evergreen story; marketing posts are transient.\n\n## Storage + seeding\n\n`postcards` table self-heals on first use (FTP deploys do not run migrations). On first\nrun, `postcardSeedIfEmpty()` loads 31 authored starter cards across all seven lanes\n(`postcardSeedSet()`) so the collection has a real voice from day one.\n\n## Reuse\n\nPostcards are source material: the doozer.work \"Because\" copy, an About page, a press\nkit, the investor narrative, marketing seeds. Write once, retell often. They are the\nhuman-readable companion to [Atlas](../decisions/okf-adoption.md) (machine-readable) and\nthe distilled [Doozer Story](../decisions/the-doozer-story.md)."
        },
        {
            "id": "promo-and-offers",
            "title": "Promo Codes + the one-time claim offer",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "promo",
                "coupons",
                "stripe",
                "billing",
                "doozer-plus",
                "claim",
                "offer",
                "monetization"
            ],
            "path": "systems/promo-and-offers.md",
            "links": [
                "claim-codes"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Promo Codes + the one-time claim offer\n\nTwo things, one file (`includes/promo-codes.php`), one Stripe primitive (coupons). Shipped\nv2.2.411-413. All Stripe calls go through `billingStripeRequest()`; creating a coupon is\nconfig, not a charge — money only moves when someone completes checkout.\n\n## The Promo Code Generator (build-once)\n\nAdmin: **Revenue → Promo Codes** (`admin-promo-codes.php`). CLI: `dz promo do=create|list|void|lifetime`.\n`promoCodeCreate($opts)` creates a real Stripe **coupon** + **promotion_code** (typeable at\ncheckout) + a local `promo_codes` row. Four shapes, extensible:\n\n| kind × duration | Stripe | meaning |\n|---|---|---|\n| percent · forever | `percent_off` + `duration=forever` | X% off every renewal, FOR LIFE |\n| percent · once | `percent_off` + `duration=once` | X% off first period, regular after |\n| amount · once | `amount_off` (cents) + `duration=once` | $X off first period, regular after |\n| amount · forever | `amount_off` + `duration=forever` | $X off every renewal, FOR LIFE |\n\n(`repeating` + N periods also supported.) One-time-use by default (`max_redemptions=1`).\n`promoCodeVoid()` deactivates the Stripe promo code + local row. Codes apply to any\nsubscription SKU (default `premium_yearly`). The manual \"someone begs\" recovery code is just\n`kind=percent value=50 duration=forever` → e.g. `PLUS50LIFE`.\n\n## The one-time claim → Doozer+ offer\n\nWhen someone CLAIMS their site is the perfect moment. On `?ajax=claim_code_redeem` success\n(`index.php`), if the business has never been shown the offer, attach it; the redeem page\n(`redeem-code.php` → `showClaimOffer`) shows **Doozer+ 50% off for LIFE, $49.50/yr forever**\nwith exactly two buttons:\n\n- **Accept** → `?ajax=claim_offer_accept` → `billingCreateCheckoutSession('premium_yearly', …, $coupon)`\n  with the lifetime coupon `promoLifetimeCoupon()` (`percent_off:50, duration:forever`,\n  deterministic id `doozer-plus-50-forever-<mode>`) auto-applied. Marks the offer `accepted`.\n- **Decline** → `?ajax=claim_offer_decline` → marks `declined`. Gone for good.\n\n**Truly one-time:** `business_offers` (UNIQUE `(business_id, offer_key)`); `promoBusinessOfferShow()`\nuses `INSERT IGNORE` and returns true only the FIRST time — so a 2nd scan, a resend, or a\nrevisit never re-shows it. No code, no take-backs. Doozer+ ONLY (never Keeper); all future\nupgrades at regular price. The admin recovery code is the deliberate escape hatch for someone\nwhose card failed.\n\n`billingCreateCheckoutSession($sku,$user,$s,$c,$extraMeta,$coupon='')` — a non-empty `$coupon`\nauto-applies `discounts[0][coupon]` and drops `allow_promotion_codes` (Stripe rejects both).\n\nRelated: [billing](../../includes/billing.php), [claim codes](claim-codes.md),\n[implicit verification]([[project_implicit_verification]]), [[feedback_right_offer_right_audience]]."
        },
        {
            "id": "prospector",
            "title": "Prospector (discovery + demand-driven growth)",
            "type": "System",
            "group": "systems",
            "description": "How Doozer finds new businesses to build — the Grand Opening Finder, Cloud Radar, and the hybrid Google Places discovery engine.",
            "tags": [
                "prospector",
                "discovery",
                "growth",
                "places",
                "demand"
            ],
            "path": "systems/prospector.md",
            "links": [
                "site-creation",
                "clouds",
                "no-dark-patterns-ethos",
                "marketing",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Prospector\n\nProspector is how the fleet grows on purpose instead of at random: it discovers\nreal businesses worth building and feeds them into\n[site creation](../processes/site-creation.md) and [Clouds](clouds.md).\n\n## Grand Opening Finder\n\nSingle-shot discovery → filter → review → one-click create. A sweep resolves a\ncenter (ZIP or city/state via `prDiscoveryResolveCenter()` in\n`includes/prospector-discovery-engine.php`), queries Google Places, and keeps only\n**fresh openings** (OPERATIONAL + very few reviews + not already known). Results\nland in `pr_discoveries` / `pr_discovery_sweeps` with per-call cost recorded.\nNiche-optional: pick \"Any niche\" for a regional pass or one of 39+ types.\n\n## Hybrid Places dispatch\n\nOLD Nearby Search for legacy types + Places API (New) `searchNearby` for newer\ntypes and the any-niche path, normalized to one response shape. Per-call cost is\ntracked (see [API cost discipline](../decisions/no-dark-patterns-ethos.md) is not\nit — cost discipline is an operator rule: check whether each pull costs, reuse\ncache, pull only the unavoidable remainder). Cross-root ecosystem awareness reuses\nan existing `businesses` row when a place is already known (skips paid detail +\nresearch, saving ~$0.50/business), with a same-root duplicate guard.\n\n## Scheduling + Radar\n\n`pr_discovery_schedules` + a traffic-driven runner (rate-limited via\n`platform_settings`) run sweeps automatically. **Cloud/Idea Radar** extends this\nto demand: signals like Google Search Console impressions reveal what people are\nalready looking for, so Doozer can build the [Cloud](clouds.md) or vertical the\nmarket is asking for. Discovery → build → auto-draft [marketing](marketing.md)\npost closes the loop: a new site can announce itself.\n\nProspector is the intake valve of [the flywheel](the-flywheel.md)."
        },
        {
            "id": "regeneration",
            "title": "Regeneration + Foundation (keep 2,400 sites fresh and safe)",
            "type": "System",
            "group": "systems",
            "description": "The unified regeneration engine, version stamping, and staleness detection that let the whole live fleet be improved without taking anything down.",
            "tags": [
                "regeneration",
                "foundation",
                "versioning",
                "staleness",
                "fleet",
                "ops"
            ],
            "path": "systems/regeneration.md",
            "links": [
                "architecture",
                "section-framework",
                "family",
                "muse-system",
                "the-flywheel",
                "free-tier-render-gotchas"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Regeneration + Foundation\n\nBecause a Doozer site is [generated from data](architecture.md), every site can be\nregenerated by today's pipeline. With 2,400+ live sites, the challenge is doing\nthat **safely, at scale, without ever going dark.** The Foundation Sprint built the\nmachinery.\n\n## Version stamping + staleness\n\nEvery generated artifact is version-stamped, so the system can detect which sites\nwere built by an older pipeline and are now stale. This is what makes a fleet-wide\nupgrade knowable instead of guesswork: \"which sites still need the new\n[section framework](section-framework.md) / [family](../glossary/family.md) fix?\"\n\n## The unified regeneration engine\n\n`includes/regeneration.php` — `regenerationRun($bizId, $mode)`. Modes:\n`recolor`, `redesign`, `reroll`, `content`, `full`, `fix_readability`. Each is\n**cost-aware** (content-only regen is ~$0.02 and keeps the design) and\n**quality-gated with rollback** — a regeneration that fails the quality check is\nreverted, so a bad AI run can never degrade a live site. This retires the old\n\"magical regen\" in favor of à la carte control (checkboxes: photos / vision /\nrecolor / new-muse / content / full).\n\nKey fact: **content regen never touches the muse.** The [muse](muse-system.md) is\nresolved via a shared cascade (`museResolve`), not stored per-business, so\nregenerating content leaves the design identity intact.\n\n## The cockpit direction\n\nThe Foundation endgame is one **System Health / Upgrade cockpit**: see fleet\nversion distribution, detect stale, and run the [fleet upgrade runner](the-flywheel.md)\nthat calls the unified engine at scale (e.g. converting the elks.club sites in one\npass). Supported by the [Schema Audit](free-tier-render-gotchas.md) tool\n(master-schema-vs-live drift) so a feature never silently no-ops in prod because a\ncolumn drifted.\n\nThis is the maintenance backbone that lets the [flywheel](the-flywheel.md) keep\nturning: the fleet gets better as the pipeline does, on purpose, without downtime."
        },
        {
            "id": "shortlinks",
            "title": "Shortlinks + QR routing (doozer.link)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "shortlinks",
                "qr",
                "doozer.link",
                "routing",
                "marketing",
                "lead-flywheel"
            ],
            "path": "systems/shortlinks.md",
            "links": [
                "the-observed-world"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Shortlinks + QR routing (doozer.link)\n\n`doozer.link` is a dedicated shortlink host. A QR or shared link encodes\n`doozer.link/<slug>`; the slug maps to an **editable destination**, so the same\nprinted code can be re-pointed later without reprinting. This is the routing\nlayer for the [observed-world lead flywheel](../decisions/the-observed-world.md):\nclaim postcards, direct mail, and campaign links all route through here so they\nstay dynamic.\n\n## How it routes\n\n`index.php` recognizes host `doozer.link` (or `*.doozer.link`) at the top of the\nrouting block and calls `shortlinkHandleRequest()` in `includes/shortlinks.php`.\nThat parses the first path segment as the slug, resolves it (single indexed\nlookup), increments `click_count` / `last_clicked_at` best-effort, and **302s**\nto the destination (302 + `no-store` so destination changes take effect and\nnothing caches). Unknown or inactive slugs bounce to `doozer.work` (no dead\nends); it serves its own robots.txt (disallow all).\n\nThe `shortlinks` table **self-creates on first use** (`shortlinkEnsureTable()`),\nso doozer.link works the moment DNS points here, before the migration\n(`migrations/sprint_shortlinks.sql`) is run. Columns: `slug` (unique),\n`destination_url`, `label`, `business_id`, `campaign`, `is_active`,\n`click_count`, `last_clicked_at`.\n\n## Managing + minting\n\nAdmin → Infrastructure → **Shortlinks** (`includes/admin-shortlinks.php`):\ncreate (auto or custom code), edit the destination inline, activate/deactivate\n(the off switch that does NOT break a printed QR the way delete does), per-link\nclick counts, one-click copy, and a client-side QR generator with PNG download.\n\nProgrammatic: `shortlinkCreate($destination, ['slug','label','business_id','campaign'])`\nreturns `['ok','slug','url']`; other features (claim postcards, direct mail) mint\nlinks this way. Dev preview: `?e=devapi&action=shortlink&create=<url>` /\n`&resolve=<slug>`.\n\n## Known permanent slugs\n\n- **`doozer.link/thanksio`** → `https://thanks.io/?afmc=doozer` (Jon's thanks.io\n  affiliate link). Printed as fine print on every Postcard Factory card (\"Printed and\n  mailed with thanks.io · doozer.link/thanksio\", `postcard-factory.php`), so every\n  \"where did this come from?\" is a possible affiliate signup — claim or no claim.\n\n## Operator note\n\nDNS for doozer.link points at this LAMP server (like the other alias domains);\nDoozer handles it entirely in code by `HTTP_HOST`. There is no admin or business\nsite on doozer.link — every path is a slug lookup. Mint a custom slug with\n`shortlinkCreate($url, ['slug' => '...'])` or `dz shortlink create= slug=`."
        },
        {
            "id": "shortlinks-and-qr",
            "title": "Shortlinks and QR (doozer.link)",
            "type": "System",
            "group": "systems",
            "description": "One permanent, claim-aware doozer.link + QR per site — the printed asset that flips from the claim page to the real site on claim.",
            "tags": [
                "shortlinks",
                "qr",
                "claim",
                "doozer-plus"
            ],
            "path": "systems/shortlinks-and-qr.md",
            "links": [
                "implicit-verification",
                "claim-codes",
                "the-flywheel",
                "postcards"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Shortlinks and QR (doozer.link)\n\nEvery Doozer site gets **one permanent shortlink and QR** — a real, printable asset. It\nlives at `doozer.link/<slug>` and is claim-aware, so the *same* printed code changes\nwhere it points the moment the business is claimed. Engine: `includes/shortlinks.php`\n(host `doozer.link` → `shortlinkHandleRequest` from index.php). Table: `shortlinks`\n(self-heals).\n\n## The permanent, claim-aware site link\n\n- `kind='site'` marks the canonical per-business link (`campaign` = static per-campaign,\n  the old default). `is_primary` marks THE current one (the Doozer+ vanity, once set).\n- `shortlinkForSite($bizId)` get-or-creates the primary site link (idempotent).\n- **Resolution is computed LIVE at redirect time** (`shortlinkSiteDestination`): if the\n  business is claimed (`admin_email` set) and has an active domain → the real site; else\n  → the redeem page (`?page=redeem&b=<id>` — business identified, the code is NEVER in the\n  URL, see [implicit verification](../decisions/implicit-verification.md)). So the flip\n  needs no destination edit and no chasing every claim path.\n- Claim postcards use this same link ([claim codes](claim-codes.md) `make_shortlink`), so a\n  mailed QR switches to the site on claim. Newly-claimed businesses get the link created at\n  redeem time.\n\n## The QR image\n\n`?e=qr` (in api-endpoints.php) — a same-origin QR image, proxied + cached from\napi.qrserver.com to `cache/qr/`. Params: `slug|data`, `size`, `fg`, `bg`, `format` (png|jpg).\nSame-origin (with `Access-Control-Allow-Origin:*`) is required so the owner panel can export\na **transparent PNG** on a canvas (a cross-origin image would taint it) and any surface can\n**copy the QR image** to the clipboard (`siteQrCopyScript()`, `[data-qr-copy]`, a fetch →\n`ClipboardItem` → toast).\n\n## Owner surface + on-site display\n\n- `?page=manage` (`manage-business.php`, owner-gated) → the \"Link & QR\" panel: the\n  doozer.link big with one-click copy, live QR preview, color picker, high-res downloads\n  (PNG transparent / PNG white / JPG white), scan-prompt copy, and a switch to show the QR\n  on the site.\n- On-site display: `includes/site-qr.php` draws a muse-hardened footer QR card\n  (\"Scan to keep us handy\"). Setting = `businesses.custom_settings.qr_display`\n  (off|footer|header|both); **on by default for claimed** (`siteQrShouldShowFooter`).\n  Header/hero placement is a future muse/design pass.\n\n## Doozer+ vanity\n\n`shortlinkSetVanity($bizId, $slug)` sets a friendly slug (e.g. `doozer.link/powell-flooring`)\nas the new primary; **old slugs stay active and keep resolving** (old printed QR still works —\n\"use the new one going forward\"). Doozer+ gated (`businessIsPremiumOwned`), owner-gated\n(`?ajax=manage_vanity`). Postcards auto-print the primary, so they pick up the vanity with no\nextra wiring.\n\nSee also: [claim codes](claim-codes.md), [the flywheel](the-flywheel.md),\n[postcards](postcards.md)."
        },
        {
            "id": "source-of-truth-cache",
            "title": "Source-of-truth cache (read the real website)",
            "type": "System",
            "group": "systems",
            "description": "A business's official website cached as markdown (Jina Reader) — the accuracy foundation for enrichment; enrich content, never overwrite location.",
            "tags": [
                "enrichment",
                "accuracy",
                "scraping",
                "gigo"
            ],
            "path": "systems/source-of-truth-cache.md",
            "links": [
                "location-data-enrichment",
                "firecrawl",
                "seo-cliff-recovery",
                "postcard-targeting",
                "content-generation",
                "fact-guard"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Source-of-truth cache\n\n86% of our businesses have an **official website** — the highest-signal reference material\nfor getting hours / services / menus / products RIGHT. Reading it closes the\ngenerate-from-Places accuracy gap that produces wrong menus and products. This is\n[GIGO](location-data-enrichment.md): better signal in, better site out.\n\n## How it works\n\n`includes/source-cache.php`. Fetches the business `website` as clean markdown via **Jina\nReader** (`https://r.jina.ai/<url>` — free, no key, renders JS; a hosted GET, nothing to\ninstall, which fits the FTP/no-build model). An optional `jina` api_keys entry (Bearer)\nraises limits but is not required. Cached in `business_sources` (business_id PK, url,\nmarkdown MEDIUMTEXT ≤80KB, title, bytes, status, fetched_at) — **fetch once, refresh on\ndemand** (cost discipline). Tool: `dz source biz=<id> [refresh=1]`.\n\nNote: [Firecrawl](firecrawl.md) never worked for us — Jina Reader replaces that need.\n\n## The enrichment leg (built on top of this)\n\n1. **Fact extraction** — AI over the cached markdown → structured facts (hours, services,\n   menu + prices, products, team, service area, booking URL). Accuracy-first: null when the\n   source is silent, NEVER invent.\n2. **Validation filter** — cross-check the generated Doozer site vs. the facts; the source\n   wins on conflicts; flag low-confidence for review.\n3. **Never copy prose, extract facts** — accuracy + no duplicate-content SEO hit (the\n   [SEO cliff](../decisions/seo-cliff-recovery.md) was scaled-content demotion).\n4. Deep extractions (full menus, product catalogs) are a **Doozer+** feature.\n\nPairs with [postcard targeting](postcard-targeting.md): enrich the target sites from their\nreal websites BEFORE mailing, so a scanned card lands on an accurate, better page.\n\n## GEO-ANCHOR RULE (load-bearing)\n\nEnrich **content** (services / products / menu / hours / about / brand). **NEVER overwrite\nthe site's LOCATION/address** — each Doozer site is already geo-verified per its Places\nlat/lng/city. Multi-location businesses list several addresses: the homepage **header** can\nbe an admin/mailing address while the storefront we want sits behind a \"View all locations\"\ndropdown. Match the source location to OUR site's city; never take the header address. The\nvalidation filter **flags** conflicts for review — it does NOT auto-clobber. Fix accuracy\nwithout introducing errors (the taste filter).\n\nExample: Powell Flooring's homepage shows Oakland NJ (admin); the wanted address is 1811\nUnion Valley Rd, West Milford (behind the dropdown). An anomaly — most businesses are a\nclean single-address hit.\n\nSee also: [content generation](content-generation.md), [fact guard](fact-guard.md)."
        },
        {
            "id": "thanksio",
            "title": "Thanks.io Connector (direct mail, two-way)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "thanksio",
                "direct-mail",
                "postcards",
                "radius",
                "targeting",
                "webhooks",
                "claim-codes",
                "prospector",
                "intelligence"
            ],
            "path": "systems/thanksio.md",
            "links": [
                "claim-codes",
                "shortlinks",
                "ai-postcards",
                "the-observed-world"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Thanks.io Connector\n\nThanks.io is a direct-mail API (postcards, letters, notecards) with radius\naudience buying and QR/delivery webhooks. Doozer uses it **both ways** so every\nsend and every response compounds into the intelligence layer. Grounded in the\nreal API: base `https://api.thanks.io/api/v2`, auth `Authorization: Bearer <key>`.\nEngine: `includes/thanksio.php`. Config-free, self-creating tables, portable.\n\n## The three loops\n\n1. **Mail the businesses we built (claim).** `thanksIoMailClaim($businessId)`\n   mints a claim code + `doozer.link` QR ([claim-codes](claim-codes.md)) and\n   sends a postcard to the business address carrying that QR and a warm message.\n   Receiving it proves the address; scanning/entering claims the listing. Doozer+\n   \"pay first, snail mail, claim\" verification uses the same path (`plus_verify`).\n\n2. **Radius targeting → new site inventory (reverse-engineering).**\n   `thanksIoBuyRadiusSearch(address, postal, count, {record_types, append_data})`\n   buys 1–10,000 real addresses around a point ($0.05/record, +$0.20 with\n   phone/email append), filtered by `record_types`: `all`, `onlybusinesses`,\n   `newbusiness`, `newhomeowner`, `renters`, `absenteeowner`, `likelytomove`,\n   `highnetworth`, `retired`, and more. `thanksIoIngestRadiusTargets(listId)`\n   pulls the records into `thanksio_targets`; `thanksIoBuildFromTargets()`\n   resolves each business record to a Google Place (`placesSearchBest`) and\n   materializes a Doozer site (`resolvePlaceToEcosystem`). The mailing list\n   becomes site inventory — which then earns its own claim postcard. Full circle.\n\n3. **Webhooks back into intelligence.** `?e=thanksio_webhook` (gated by a shared\n   secret in the URL — Thanks.io sends no signature) ingests\n   `order.status_update` / `order_item.delivered` / `scans.scan_update` into\n   `thanksio_events`, mirrors status onto the matching `thanksio_sends` row, and\n   pings Comms: **delivered** = the address is reachable (the proof-of-address\n   precondition), **scan** = the owner engaged before claiming (a demand signal\n   for the Demand Ledger).\n\n## Tables\n- `thanksio_sends` — every send (preview or live): business, claim_code, order id,\n  recipients, authorization_total, status, is_preview, qr url, address snapshot.\n- `thanksio_events` — webhook events (unique on event_id), resolved to a business.\n- `thanksio_audiences` — radius searches bought: center, record_types, count, cost.\n- `thanksio_targets` — records pulled from a radius audience, staged for building\n  (resolve_status new/resolving/built/existing/skipped/failed + business_id/place_id).\n\n## Safety\nSpend is real and irreversible, so: every send DEFAULTS TO PREVIEW (Thanks.io\nreturns an estimate, no charge) unless `live=1`; radius buys return a cost\nestimate unless `confirm=1`; build-from-targets is bounded per call. Operator-\ntriggered only. Cost logged on every row ([[feedback_api_costs]]).\n\n**Cost-units bug FIXED (v2.2.409):** Thanks.io returns `authorization_total` /\n`leads_fee` ALREADY in cents (114 = $1.14 for a 4x6, 1 recipient); the send path\n(`thanksIoSendPostcard`) was doing `round(value * 100)` → stored 100× too high\n(11400 for a $1.14 card). Dropped the `*100`; `dz postcard do=fixcosts` re-derives\nhistorical `thanksio_sends` rows from raw `meta.response.authorization_total`.\n**Previews (proof images) live at `response.data.previews`** = [front, handwriting\nback] S3 URLs; persisted to `postcard_designs.proof_urls` for the admin lightbox.\n\n## Live postcard tracking (v2.2.407)\nDelivery/scan/claim per send are read LIVE by `postcardDesignLiveStats()`\n([[ai-postcards]]) from `thanksio_sends` + `thanksio_events` + `shortlinks` +\n`claim_codes` (never cached JSON). The webhook now also recomputes the linked\n`postcard_designs.tracking` on each delivered/scan event, so the admin \"Sent &\nperformance\" view stays fresh without a manual Refresh.\n\n## Ops + setup\n- `dz thanksio status | account | lists | webhook | radius | ingest | build |\n  mailclaim | sends | events`.\n- To go live the operator: (1) stores the API key (api_keys service `thanksio`,\n  or `platform_settings thanksio.api_key`); (2) sets the return address\n  (`thanksio.return_name/address/city/state/postal_code`); (3) sets a claim\n  postcard front (`thanksio.claim_image_template_id` designed in Thanks.io, or\n  `thanksio.claim_front_image_url`); (4) runs `dz thanksio do=webhook` to register\n  the receiver. Then preview a `mailclaim`, and add `live=1` when happy.\n\n## Back-of-card copy + response levers (v2.2.392)\n\nThe front is the Factory image; the BACK is where direct-mail response is won.\n`thanksIoClaimMessageVariants($bizName,$code,$opts)` returns **warm / leads / curious**\nvariants, personalized with the town, no em-dashes. **The message never spells out the\nclaim code or the doozer.link URL** (v2.2.405): cursive handwriting makes codes/URLs hard\nto read, and a code is already a friction point, so the back POINTS to them instead:\n\"Scan the QR code, or use the claim code printed on the front of this card.\" Code + URL\nlive only where they're legible: the front. Greeting frames the business as the OBJECT,\nnot a salutation: \"Hi, we built Powell Flooring a real website...\" (warm \"you / yours\"),\nnot \"Hi Powell Flooring, we built you...\".\n`thanksIoDefaultClaimMessage` picks one (`opts.variant`). Operator levers (all\nplatform_settings, flowed through `thanksIoMailClaim`): `thanksio.handwriting_style_id`\n(a handwritten back lifts response), `thanksio.size` (4x6 default; 6x9/6x11 stand out\nbut need a matching front aspect), `thanksio.message_variant`. Multi-campaign:\n`postcardCampaignMessage` resolves the back copy per campaign (claim → these variants;\nother campaigns → their `message_template` with tokens).\n\nRelated: [claim codes](claim-codes.md), [shortlinks + QR](shortlinks.md),\n[AI Postcards](ai-postcards.md) (campaigns + Factory + Theme Studio),\n[the observed world](../decisions/the-observed-world.md) (mail closes the CLAIM\nloop; radius feeds OBSERVE), Prospector / place-resolver."
        },
        {
            "id": "the-now-surface",
            "title": "The /now surface and the dense directory — the app NearByMeNow already almost was",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "nbmn",
                "pwa",
                "mobile",
                "ui",
                "populate",
                "measurement",
                "app"
            ],
            "path": "systems/the-now-surface.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The /now surface and the dense directory\n\n**The operator's idea:** open an app wherever you are, press one button, see the nearest\nfew businesses, and either visit a live Doozer site or **build one standing right there**.\nThe person using it IS a Doozer — their presence is the contribution.\n\n**The finding that shaped the work:** almost all of it already existed.\n`checkin-button.php`, `follow-button.php`, `user-hotspots.php`, `business-card.php`, a\n1,391-line Foursquare-style `nbmn-map-home.php`, and the endpoints `?e=businesses_near`\n(geo-sorted JSON), `?e=nbmn_populate`, `?e=nbmn_build_place`. `/now` is assembly.\n\n## `/now` (`includes/nbmn-now.php`)\n\nCookie-first location (`doozer_user_location`, one prompt per network, **never IP-geo**),\nthen `businesses_near` + `nbmn_populate` merged into one distance-sorted list. Built\nplaces say **Open**; unbuilt say **Build it**. 86px rows. Route suppresses the site header\nAND the 600px directory footer — an app shell is not a directory page.\n\nInstallable: `nearbymenow/manifest.webmanifest`, `nearbymenow/sw.js`, `assets/pwa/*`.\n\n## MEASURED: what the category page was doing wrong\n\n| | before | after |\n|---|---|---|\n| first business | 1,303px (1.32 screens) | **419px** |\n| card height | 388px | **78px** |\n| on the first screen | 2 | **5** |\n| page height (24 results) | 11,995px | **4,611px** |\n| horizontal overflow | 312 elements | none |\n\nFixes: a `compact` **surface** on the shared card (same markup, denser CSS — never a\nsecond renderer, which would drift from the image fallback / toolbar / call modal); the\nbanner and type-chips folded behind one \"Filter & sort\" toggle; the header search form\ncapped (it was 456px inside a 375px viewport and caused every overflowing element).\n\n## GOTCHAS, all of which looked fine to a DOM probe\n\n1. **The front controller served `manifest.webmanifest` and `sw.js` as HTML.** A browser\n   REFUSES to register a service worker with the wrong MIME type. Serve PWA files\n   **before** the router, with explicit `Content-Type`.\n2. **Cache-first on an app shell serves a stale page** and reads exactly like a failed\n   deploy. Use **network-first with cache as the offline fallback** for anything still\n   changing.\n3. **A component honouring `prefers-color-scheme` on a host page with a hardcoded light\n   body** gives dark rows on white. The shell must OWN `html, body`, tokens on `:root`.\n4. **The host styles `header` at TAG level**, so any `<header>` inside a component\n   inherits it. Same family as the business-site `!important` chrome rule.\n5. **A script that snapshots elements at parse time misses markup below it.** The chrome\n   toggle grabbed `.nbmn-chrome` 130 lines before the second block existed, and the page\n   half-collapsed. Query at call time; apply after `DOMContentLoaded`.\n\n**The discipline that caught 1, 3, 4 and 5: screenshots.** Every one of them returned\nhealthy values from `getBoundingClientRect` and friends. Only looking at the rendered\npixels showed a white strip, unreadable text, and a half-open filter panel.\n\nRelated: [[nearbymenow]], [[shared-business-card]], [[events-to-machines]],\n[[the-observed-world]]."
        },
        {
            "id": "architecture",
            "title": "The Architecture (one file, unlimited sites)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "architecture",
                "routing",
                "multi-tenant",
                "index",
                "core"
            ],
            "path": "systems/architecture.md",
            "links": [
                "site-creation",
                "clouds",
                "livemusictonight",
                "muse-system",
                "free-tier-render-gotchas",
                "deployment",
                "content-generation",
                "the-flywheel",
                "location-data-enrichment"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Architecture\n\nDoozer is a **single-file multi-tenant** application. There is one entry point,\n`index.php`, and the whole network runs through it. What renders is decided by\nthe request's host, not by separate codebases or a CMS per site.\n\n## The request, start to finish\n\n1. `index.php` reads `$_SERVER['HTTP_HOST']` and classifies the request:\n   - **Master** (`doozer.work`) — the platform landing page + admin console. Never a\n     business page.\n   - **API** (`?e=...` / `?ajax=...`) — routed to `includes/api-endpoints.php` (the\n     `?e=` read+write API) or the inline `?ajax=` action switch.\n   - **A root apex** (e.g. `elks.club`, `vsso.net`, `westmilford.biz`,\n     `nearbymenow.com`) — the [root/Cloud directory](../processes/site-creation.md)\n     listing many businesses.\n   - **A business subdomain** (e.g. `oakridgeflorist.westmilford.biz`) — a single\n     generated business site, rendered by `includes/business-site.php`.\n   - **A Cloud host** or a **Vertical Directory** (e.g. `livemusictonight.com`) —\n     see [Clouds](clouds.md) and [Live Music Tonight](livemusictonight.md).\n2. The `domains` row for the host resolves the `businesses` row (the real data).\n3. `business-site.php` composes the page: [muse](muse-system.md) CSS/type, the AI\n   `ai_generated_content`, photos, sections, contact + lead form, the network footer.\n\n## Why one file\n\nDeployment is a file copy over FTP (no build step, no per-site config). A new\nwildcard domain becomes a live business generator the moment DNS points at the\nserver. \"Monolith with internal structure\": the logic lives in `includes/*.php`\nhelpers, not scattered apps. The tradeoff is discipline. A regression in\n`index.php` or `business-site.php` touches the whole fleet, so we\n[verify by looking at live pixels](free-tier-render-gotchas.md) and\n[deploy with backup + rollback](../processes/deployment.md).\n\n## What a site actually IS\n\nA Doozer site is **generated from data + a Muse**, not hand-built. The inputs\n(Google Places data, scraped enrichment, reviews) persist in the DB; the output\nis recomputed by today's pipeline. \"Converting\" or \"upgrading\" a site = re-running\nthe current [generation pipeline](content-generation.md) over the same inputs. The\nsite never goes dark, and it gets better as the pipeline improves. This is the\npremise behind [the flywheel](the-flywheel.md) and the whole\n[data-enrichment](location-data-enrichment.md) direction."
        },
        {
            "id": "chamber-chamber",
            "title": "The Chamber Chamber",
            "type": "system",
            "group": "systems",
            "description": "",
            "tags": [],
            "path": "systems/chamber-chamber.md",
            "links": [],
            "status": "stable",
            "generated": {
                "by": "claude-fable-5-1",
                "at": "2026-09-12"
            },
            "verified": [
                {
                    "by": "jon-berry",
                    "at": "2026-09-12"
                }
            ],
            "stale_after": "2027-03-12",
            "sources": [
                ""
            ],
            "body": "# The Chamber Chamber\n\n**Find the people building your town.** A Chamber of Commerce already gathers a town's\nbusinesses around a shared purpose. The Chamber Chamber (`thechamberchamber.com`) is a\ndirectory of those Chambers with every member connected to its own Doozer page.\n\n## What it is built on\n\nNothing new is collected. Directory Harvest (`directory_sources`, `directory_members`,\n`dz harvest`) already reads a Chamber's public roster once, matches members to businesses\nwe hold or builds claim-ready sites, and records the read with its source URL and date.\nThe Chamber Chamber renders that record and **spends nothing** on a page view.\n\n## The contract (from the September 2026 handoff)\n\n- Every roster names its source URL and the date it was read.\n- Membership means \"this source listed them on this date\". It is not a rating, an\n  endorsement, a benefit or a partnership. The page says so.\n- A member missing from a later read is \"not seen in the latest roster\", never removed and\n  never \"expelled\".\n- Member emails are used only to reach a business about its own page. **Never shown.**\n- No dead forms. \"Add your Chamber\" and \"Claim or correct\" are mailto links to\n  `agencies@doozer.work`; a Chamber joins with one email.\n- Categories and roster order are the Chamber's own free text, grouped as given.\n\n## Routes\n\n| path | what |\n|---|---|\n| `/` | every harvested source, members-listed and on-Doozer counts, client-side filter |\n| `/<chamber-slug>/` | source + read date, four facts, town board link, members by category |\n| `/robots.txt`, `/sitemap.xml` | own branches in `index.php`, next to the town host |\n\nSlug = `ccSlug(name)`. The town board link is `townSlugify(city, state)` from the\nsource's `region` (\"West Milford, NJ\").\n\n## Members on the page\n\n- **Linked** (`business_id` set, active domain): the shared `renderBusinessCard` compact\n  surface, civic navy `root_color`. Never a second card renderer.\n- **Unlinked**: name, address, \"Their website\", \"Call\", and when a `place_id` is known a\n  \"Build their page\" button that posts to the existing `?e=nbmn_build_place` through the\n  shared build modal. A visitor building a member's page is the honorary-Doozer loop from\n  Doozer Town, applied to a Chamber.\n\n## doozer.world\n\n`includes/doozer-world.php`: the why in two screens. Three-line thesis (Doozer knows the\nworld; Logfile knows your trail through it; Wuz lets you go back), why it begins with\nwebsites, thirteen doors each phrased as one human question with an honest status\n(live / new / soon / concept; only live doors link), and three paths: I run a business,\nI run a Chamber, I live somewhere. No forms, counters or testimonials.\n\n## Not built yet, on purpose\n\n- Logfile.me (Jon is moving the domain; the memory products wait on the trust decisions).\n- A first-class `observation` row (observed_at, valid_from, valid_to, source). Today the\n  read date lives on `directory_sources.last_harvested_at`, which is enough for one page\n  but not for history.\n- Re-harvest scheduling. A second read of a roster is what makes \"not seen in the latest\n  roster\" real."
        },
        {
            "id": "brand-system",
            "title": "The Doozer Brand System (ecosystem identity kit)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "brand",
                "design",
                "identity",
                "palette",
                "typography",
                "ecosystem",
                "ui",
                "craft"
            ],
            "path": "systems/brand-system.md",
            "links": [
                "no-dark-patterns-ethos",
                "the-network",
                "reference_niche_color_system",
                "vibe-system",
                "the-mission",
                "project_elks_root_theme",
                "muse-system"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Doozer Brand System\n\n## Essence\n\nDoozer **builds**. The personality is an industrious, honest builder: useful,\ngrounded, warm, confident, a little utilitarian. Never slick-for-its-own-sake,\nnever hype, never a [dark pattern](../decisions/no-dark-patterns-ethos.md). The\nsign-off is the tagline **\"We Build Because.\"** The feeling: *someone quietly\nbuilt you something good, and it works.* Optimistic green, warm paper, a spark\nof gold.\n\n## The mark — THE LATTICE (Aug 2026)\n\n`doozerMarkSvg()` in `includes/doozer-brand.php`. **It replaced a 3D construction\nhelmet on a gear.**\n\nThe helmet named the inspiration literally, and the inspiration was never the point.\nIn Fraggle Rock the Doozers are not memorable for what they wore — they are memorable\nfor what they **built**: crystalline lattice, assembled tirelessly and joyfully, eaten,\nand built again. The legacy worth keeping is the structure, not the worker.\n\nThe mark is a **D built from a grid**, most cells set, one gold, one still open. It\nreads three ways at once:\n\n| reading | what it is |\n|---|---|\n| physical | a street grid, a block, a town from above |\n| digital | a pixel grid, a data structure, a disk defragmenting |\n| the product | an open cell is a place nobody has opened yet |\n\nThe third reading is the software. It is the same idea as the blueprint plate the\ndirectory draws for an unopened listing, so the brand and the product finally say one\nthing. 1994's pixel grid and what we build with now are the same lattice at different\nresolutions.\n\n**The incompleteness is the point.** The cells are ordered as if built up from a\nfoundation: base and body set, the gold cell going in at the top edge, one outline\nbeside it waiting. The mark is unfinished because the network is.\n\nRules: drawn from `currentColor` plus one accent, so it inherits ink on any surface\nand needs no light/dark variants. Below 20px every cell fills solid — the story is for\nsizes where it can be read. **1.3KB of vector** in place of a 578KB PNG.\nStatic files: `assets/images/doozer-mark.svg` (on green, for favicons) and\n`doozer-mark-mono.svg`.\n\nTwo earlier cuts were built, screenshotted and rejected: a 5x5 whose two special cells\nread as stray teeth, and a 7x7 three-step fade that destroyed the silhouette below\n24px. Neither survives in the code.\n\n## The one rule (how the ecosystem coordinates)\n\nEvery property shares the **same skeleton** and changes only its **accent hue**:\n\n- Shared across ALL properties: the neutral palette (paper / ink / cream /\n  muted / line), the type system, the logo lockup + \"We Build Because.\"\n  sign-off, the [network footer](../systems/the-network.md), the sponsor bar,\n  the voice, and the component shapes (radius, shadow, buttons, inputs, focus).\n- Owned per property: **one accent family.**\n\nMoving from doozer.work to LiveMusicTonight to GoHe.re should feel like walking\nbetween rooms of one building: same architecture, same materials, a different\ncolor of light. This is the coordination the operator wants: distinct, not\nuniform; unmistakably one network.\n\n## Color\n\n### Core neutrals (every surface)\n```\n--paper:#faf8f2   warm page ground        --line:#e4e0d3   hairline borders\n--cream:#f2efe4   secondary ground        --card:#ffffff   raised surfaces\n--ink:#111b14     near-black green ink     --ink-soft:#33463a  body-dim\n--muted:#5a6b60   captions / meta\n```\nDark grounds (hero bands, dark sections): `#0a0f0b #0d1a12 #111b14 #1a2e20`.\n\n### Brand green (the anchor accent)\n```\n--green:#12833c        primary brand green\n--green-dark:#0c5c2a   buttons + small text on light (AA)\n--green-bright:#29b356 hover / lively accent\n```\nRetuned Aug 2026, deeper and more luminous than the old `#1f9d3f`. The colours were\nnever the problem; the PAIRING was. Safety-green beside hi-vis amber **in equal\nmeasure** is a work-site palette, and it was carrying more of the construction signal\nthan the helmet did.\n\n### Gold spark\n```\n--gold:#ffc107      the piece being set — and nothing else\n--gold-deep:#c98a00 gold as text on light\n```\nGold is no longer a co-equal brand colour. It appears **once**, small, as the active\ncell in the mark and as a genuine highlight. Green carries the identity.\n\n### Contrast rules (non-negotiable, AA floor)\n- Body text = `--ink` on `--paper`.\n- Solid buttons = `--green-dark` bg + white text (AA). Do NOT put white text on\n  `--green` (#1f9d3f is AA-large only) or on `--gold`.\n- Gold is a **background / eyebrow** color with ink text, never white-on-gold.\n- Always ship visible `:focus-visible` (gold outline works on every ground).\n\n### Ready-to-paste tokens\n```css\n:root{\n  --paper:#faf8f2; --cream:#f2efe4; --ink:#111b14; --ink-soft:#33463a;\n  --muted:#5a6b60; --line:#e4e0d3; --card:#fff;\n  --green:#12833c; --green-dark:#0c5c2a; --green-bright:#29b356;\n  --gold:#ffc107; --gold-deep:#c98a00;\n  --accent:var(--green); --accent-dark:var(--green-dark); /* override per property */\n}\n```\nA page only needs to redefine `--accent` / `--accent-dark` to reskin for its\nproperty. (Centralizing these as a `brandCssVars()` PHP emitter is the natural\nnext step so no surface hand-copies them.)\n\n## Per-property accent map\n\n| Property | Accent | Ground | Feeling |\n|---|---|---|---|\n| Core (doozer.work, claim, auth, admin) | builder green `#12833c` / `#0c5c2a` | paper | the workshop |\n| NearByMeNow | star gold `#f5a623` (+ the [niche-color rainbow](../../reference_niche_color_system.md) for content) | paper | the town square |\n| LiveMusicTonight | electric violet `#8b5cf6` + magenta `#ec4899` highlight | dark-first (`#0d1a12`) | after dark |\n| GoHe.re (gatherings) | warm tangerine `#f97316` | paper | come together |\n| Wuz.ai (presence) | electric cyan `#06b6d4` | paper / light | you are here, now |\n| AwesomeAddresses (homes) | slate `#334155` + gold | paper | home, premium |\n| Doozer.Cloud / personal | adaptive ([Vibe System](../systems/vibe-system.md): Mood x Energy) | user-chosen | your world |\n\nThe accents are hue-distinct but all sit on the SAME neutrals and type, so they\nread as siblings. Directories (NBMN) also use the network-wide category-color\nsystem for content chips; the property accent governs its chrome.\n\n## Typography\n\n**System sans** everywhere: `ui-sans-serif, system-ui, -apple-system,\nBlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif`. This is a\ndeliberate choice, not a default: it is fast, free (no webfont cost or FOUT),\nuniversal, and its plainspoken honesty is on-brand for a builder. The character\ncomes from **weight, scale, tracking, and the green** — not a novelty face.\n\n- Display / H1: same family, weight **800**, tracking **-0.02 to -0.025em**,\n  size `clamp(2rem, 7vw, 3rem)`, line-height ~1.05.\n- Headings: 700-800, tight tracking.\n- Body: **>= 16px**, line-height ~1.55, `--ink-soft` for long prose.\n- Eyebrow: 12.5px, `letter-spacing:.16em`, uppercase, weight 800, `--gold-deep`.\n- Mono (`ui-monospace, SFMono-Regular, Menlo, monospace`): domains, codes,\n  data, addresses.\n\nException: **Doozer+ bespoke sites and the 100 muse families may use expressive\ndisplay faces** — that is the paid tier's and the product's differentiation. The\n*network chrome* stays system-font.\n\n## Space, shape, elevation\n\n- Radius scale: **8 / 11 / 14px** (chips / controls / cards).\n- Reading column: **620-680px**; directories/dashboards go wider.\n- Cards: `--card` on `--paper`, `1px solid --line`, soft shadow\n  `0 18px 40px -24px rgba(17,27,20,.5)`. Primary cards get a **5px left border**\n  in `--accent`.\n- Generous padding (20-24px in cards); let type breathe.\n\n## Motion\n\nSubtle and orchestrated, never scattered. A single **fade-up on load**\n(`translateY(10px)->0`, ~0.6s, staggered 60-80ms) is the house move. Always wrap\nin `@media (prefers-reduced-motion: reduce)` and disable transforms.\n\n## Components (house patterns)\n\n- **Button primary:** `--accent-dark` bg, white, radius 11px, weight 800,\n  `:active` nudges 1px, `:focus-visible` gold outline.\n- **Button ghost:** `--paper` bg, `--ink` text, `1.5px --line` border.\n- **Input:** `--paper` bg, `1.5px --line`, focus = accent border + soft accent\n  ring. Body-size text.\n- **Eyebrow:** gold-deep, uppercase, tracked (see type).\n- **Browser-frame preview:** a \"site card\" with a bar (three dots + mono\n  domain), a cached page **screenshot** (or a branded placeholder), and a\n  tap-to-open row. See `includes/claim-listing.php` `claimPreview()`.\n- **Network footer / sponsor bar:** shared components\n  ([network-footer](../systems/the-network.md), sponsor-bar); every property and\n  every muse site carries them so even a wild site feels networked.\n\n## Voice\n\nPlain, active, warm, confident. Name things by what the user controls. Errors\nare honest and helpful, not apologetic or vague. **No em-dashes** in\nuser-visible copy (periods / commas / `·`); apostrophes fine. No slop, no hype,\nno invented taglines (the tagline is fixed: \"We Build Because.\"). See the\n[mission](../decisions/the-mission.md) and `includes/design-standards.php`.\n\n## Accessibility floor (ship every time)\n\nAA contrast (rules above), visible keyboard focus, `prefers-reduced-motion`\nrespected, mobile-first, body >= 16px, real `alt`/labels.\n\n## Chrome vs. muse (where the brand applies)\n\n- **Network chrome** — human-authored surfaces (landing, claim, auth, admin,\n  per-root themes like [elks](../../project_elks_root_theme.md)) and the wrappers\n  around every site (footer, sponsor bar): **governed by this brand system.**\n- **Business sites** — the 100 [muse families](../systems/muse-system.md) are\n  deliberately diverse; that variety IS the product, governed by\n  `design-standards.php` + `muse-apply.php`. They still wear the shared chrome.\n\n## Where it lives in code\n\nTokens currently declared inline on `includes/landing-page.php` and\n`includes/claim-listing.php`; category content color in\n`includes/niche-colors.php`; site-generation rules in\n`includes/design-standards.php`. **Next step to move faster:** a\n`brandCssVars($property='core')` emitter (a small `includes/brand.php`) so any\nnew page drops in the tokens + its accent in one line instead of re-declaring\nthem. This node is the source of truth until then."
        },
        {
            "id": "the-flywheel",
            "title": "The Doozer Flywheel",
            "type": "System",
            "group": "systems",
            "description": "How Doozer, NearByMeNow, LeadBuilder, and Prospector reinforce each other into a self-feeding local-business engine.",
            "tags": [
                "strategy",
                "flywheel",
                "network"
            ],
            "path": "systems/the-flywheel.md",
            "links": [
                "muse-system",
                "niche",
                "shared-business-card",
                "implicit-verification",
                "index"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Doozer Flywheel\n\nFour surfaces over one dataset, each feeding the next:\n\n- **Doozer** builds the business sites (one codebase, many domains, the\n  [Muse System](muse-system.md) styling each).\n- **NearByMeNow (NBMN)** is the directory/traffic layer — geo + [niche](../glossary/niche.md)\n  discovery that routes visitors to the sites and back (the\n  [shared card](shared-business-card.md) renders the listings).\n- **LeadBuilder** captures and routes demand: [call intent](shared-business-card.md),\n  lead requests, and `lb_events` telemetry become qualified leads for owners.\n- **Prospector** mines real demand (GSC search data, inventory gaps) to decide what to\n  build or grow next — the Idea/Cloud Radar.\n\nThe strategic fact underneath: Doozer builds, organizes, hosts, attracts, markets, and\nTRACKS the local physical business economy in real time. Owners verify by\n[implicit verification](../decisions/implicit-verification.md) (no claim system). The\ngraph of who-is-where-in-what is exactly what the OKF\n[network nodes](../index.md) expose."
        },
        {
            "id": "the-doozer-score",
            "title": "The Doozer Score — the Stick, the Tower, and the leverage between them",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "metrics",
                "cost",
                "value",
                "digest",
                "telegram",
                "strategy"
            ],
            "path": "systems/the-doozer-score.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Doozer Score\n\nFraggle Rock Doozers build towers out of construction sticks. So do we.\n\n- **STICK** — what 100 Doozer sites cost to build. Measured from our own ledgers\n  (`ai_generation_history` + `places_api_calls`) over a trailing 30 days, never estimated.\n- **TOWER** — what those same 100 sites would cost the owners if a human web developer\n  built them. Replacement cost at `doozer_score_site_value` (default **$750/site**, a\n  conservative floor for a small-business one-pager).\n- **DOOZER SCORE = TOWER / STICK** — leverage. How many dollars of delivered work every\n  dollar of spend produces. It rises when cost falls **or** value rises, which is exactly\n  the pair of things worth watching together.\n\nFirst reading (2026-08-22): **Stick $24.66 · Tower $75,000 · Score 3,041x.**\nNetwork at replacement value: **$2.70M** across 3,602 sites.\n\n## Where it appears\n\n`includes/doozer-score.php`, `dz doozerscore [days=30] [record=1]`.\nA row lands in **`doozer_score_daily`** every time the pulse or digest runs, so the trend\nis *measured* rather than recomputed from memory. Rendered in:\n\n- the **Telegram pulse** (`doozerScoreLine()`), deliberately right next to the spend line\n  so cost is never read without value beside it;\n- the **operator edition of the Doozer Digest** (`doozerScoreHtml()`).\n\n**It is never shown to subscribers.** The digest email goes to real people who followed a\nlocal business; they get their neighbourhood news, not our unit economics. The score is\nattached only when `$userId === null` (the un-personalized network edition).\n\n## The honesty rules — this number is flattering and could rot into vanity\n\n1. **TOWER IS REPLACEMENT COST, NOT REVENUE.** It is what the work would cost to buy\n   elsewhere. Nobody paid it. `realized_usd` reports actual revenue separately and the\n   two are **never blended**. Today realized is $0, and the digest says so out loud.\n2. **The rate is an assumption and is always stated** wherever the score is shown.\n   Change `doozer_score_site_value`, change the number.\n3. **STICK is marginal production cost only** — AI plus Google APIs. It excludes hosting\n   and operator time, and says so.\n\n## Why it exists\n\nDoozer got a $1,300 surprise bill because cost was invisible. The reflex fix was a spend\ncap, which controls cost but tells you nothing about whether the spending is *worth it*.\nThe Score is the other half: it makes \"we got cheaper\" and \"we got more valuable\" a single\nline the operator reads every morning.\n\nMoving the writer to `gpt-5.4-mini` ([[writer-model-and-accuracy]]) took the AI share of a\nStick from $3.40 to $0.75.\n\nRelated: [[the-flywheel]], [[writer-model-and-accuracy]], [[events-to-machines]]."
        },
        {
            "id": "elks-root-theme",
            "title": "The Elks Root Theme (bespoke root identity, a case study)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "elks",
                "root",
                "theme",
                "identity",
                "case-study",
                "design"
            ],
            "path": "systems/elks-root-theme.md",
            "links": [
                "the-network",
                "muse-system",
                "two-tier-model",
                "free-tier-render-gotchas",
                "location-data-enrichment"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Elks Root Theme\n\n`elks.club` demonstrates the pattern for giving a whole [root](the-network.md) a\n**bespoke identity in code**, layered over the per-site [Muse](muse-system.md)\nrather than replacing it. It is not a DB muse; it lives in\n`includes/elks-root.php`, gated by `elksIsRoot()`, and is applied via\n`html body`-scoped `!important` CSS so it wins over arbitrary muse rules without\nediting them.\n\n## What it does\n\nA dignified B.P.O.E. (Benevolent and Protective Order of Elks) treatment for\nJuly 4th and beyond: deep navy + deep red with professional gradient fades,\nPlayfair display type, a Virtues ribbon (Charity / Justice / Brotherly Love /\nFidelity), and heritage bands. Signature elements:\n\n- **RWB LED bar** — an animated red-white-blue \"patriotic laser\" divider (tricolor\n  sweep + hairline edges + glow), replacing a flat border. Repeats as a footer\n  masthead rule.\n- **Footer masthead** — three page-centered cells (Lodge ID left, legal center\n  under the white LED segment, `elks-white-logo.webp` right).\n- **Gold edges** — a tunable gold pinstripe only at blue↔non-blue section seams\n  (never on the LED bar), plus a hero-top pinstripe.\n- **The \"11\" wax-seal jewel** — a custom SVG divider (gold radial disc, dashed\n  wax-seal ring, embossed bronze \"11\") marking the Eleven O'Clock remembrance, at\n  specific dark-band boundaries.\n- **Marketing surfaces** — a \"Who are the Elks?\" centered modal + Join CTA to\n  `https://join.elks.org`, sourced from official Elks material.\n\n## Why it is the pattern\n\nA root can carry a shared identity that is *cohesion, not monoculture*: every lodge\nsite keeps its own content and photos, but shares the star-spangled chrome. This\nis the [premium / Doozer+](../decisions/two-tier-model.md) direction proven on a\nreal fleet. The build taught durable lessons folded into\n[render gotchas](free-tier-render-gotchas.md) and the site-quality backlog:\nnative `<dialog>` centering needs `position:fixed`; scroll-reveal must force\n`transform:none` on forced-visible bands; PHP heredoc octal escapes (`\\\\2605`);\ngold-edge specificity must exceed muse button rules. Content quality still follows\n[GIGO](location-data-enrichment.md) — the theme makes a lodge look dignified; real\n[enrichment](location-data-enrichment.md) makes it sound like itself."
        },
        {
            "id": "growth-engine",
            "title": "The Growth Engine — observe → serve → convert → learn",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "growth",
                "acquisition",
                "monetization",
                "flywheel",
                "doozer-plus",
                "postcards",
                "hot-leads",
                "intelligence",
                "strategy",
                "overview"
            ],
            "path": "systems/growth-engine.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Growth Engine — observe → serve → convert → learn\n\nThis is the map. Doozer's whole reason to make money runs on one flywheel; the detailed\nmechanics live in the component nodes linked throughout. Read this first, then dive where\nyou need to.\n\n## The purpose (the WHY)\n\nRealizes the [[the-observed-world]] pivot: **every business exists once observed** — we paint\nit a real, live site on demand, and it already has leads waiting. From there:\n\n> They know us → we send them value (real leads) → we make the right offer at the right moment\n> → free becomes Doozer+ ($99/yr) becomes Cloud Keeper ($499/yr) becomes agency/franchise.\n\nWhen a business is aware of Doozer AND receiving leads from us, **$99/yr feels like a stepping\nstone**. The engine's job is to (1) create that awareness + value, (2) recognize the moment,\n(3) make the honest offer, (4) measure what worked, (5) do more of it. No dark patterns\n([[project_doozer_ethos]]); anonymous browsing + the free claim always work.\n\n## The stages + the wiring\n\n**1. OBSERVE + PAINT.** A business is discovered ([[prospector]]) or rendered on demand; the\nFactory paints a real site (screenshot + real content). [[the-observed-world]], [[network-growth]].\n\n**2. SERVE VALUE (leads).** Directory + contact forms capture consumer leads; they route to the\nbusiness's one inbox (`businessLeadRecipientEmail`, admin_email→scraped fallback) and pipe to\nLeadBuilder (`nbmnLeadPipeToLeadBuilder`). The lead-delivery email itself carries the right\nDoozer+/claim pitch (below). This is the \"we give value\" signal — the strongest driver of\nconversion readiness.\n\n**3. REACH (the touch).** Physical mail is the workhorse when we have no email:\n- **Postcard Factory** ([[ai-postcards]]) — deterministic HTML→PNG card: real screenshot,\n  real claim code, real doozer.link short URL + scannable QR, no per-card AI cost. **AI Theme\n  Studio** designs vision-gated backgrounds (two-score design+accuracy gate). **Campaigns** give\n  each card a purpose + audience. **Size is a variable** (4x6 vs 6x9, same 2:3 art) for A/B.\n- **Thanks.io** ([[thanksio]]) mails it + webhooks delivery/scan back. Send is preview-by-default;\n  live costs money (behind a confirm). Cost is per-send on `thanksio_sends` (returns cents).\n- **Claim codes** ([[claim-codes]]) — the code on the card is a strong secret; scanning the QR\n  or short link lands on `?page=redeem&b=<id>` (non-secret); the code is TYPED off the card\n  (brute-force safe). Redeeming = claiming ([[project_implicit_verification]], no claims table —\n  ownership is an email match; `admin_email` authoritative once set).\n\n**4. CONVERT (the offer).** The right offer to the right audience at every touch, from ONE\nresolver — never mis-targeted:\n- **`doozerPlusOfferFor($businessId)`** (`premium.php`) → stage `claim` / `plus` / `premium` /\n  `agency` with canonical copy (`doozerPlusBenefits`, mirrors `?page=plus`). Wired into the\n  lead email, the owner manage page, and **the owner viewing their own free site** (a hardened,\n  dismissible nudge). [[feedback_right_offer_right_audience]].\n- **The one-time claim offer** ([[promo-and-offers]]): the moment someone claims, they get a\n  truly one-time deal — **Doozer+ 50% off for LIFE ($49.50/yr forever)** — accept (Stripe checkout\n  with the lifetime coupon) or decline (gone for good). Single-fire per business (`business_offers`);\n  never from a code, a 2nd scan, or a resend. Doozer+ only, never Keeper.\n- **Promo Code Generator** ([[promo-and-offers]]): reusable Stripe-coupon-backed codes\n  (%/$ × once/forever) — including the manual \"someone begs\" recovery code. Billing is\n  product-agnostic Stripe (`billing.php`, one connection sells everything).\n\n**5. MEASURE + LEARN.** Every touch is tracked and fed back:\n- **Postcard performance** ([[ai-postcards]]): per-send delivered/scanned/visited/claimed/\n  converted, live from `thanksio_sends`/`thanksio_events`/`claim_codes`/`shortlinks`\n  (`postcardDesignLiveStats`). **Format-performance** panel compares 4x6 vs 6x9 by\n  niche/state/timeframe with thin-sample flags — the \"which format/niche/market converts\" science.\n\n## The smarts (the intelligence layer)\n\n**Hot Leads — the Internal Affairs Division of LeadBuilder** ([[doozer-prospects]],\n`doozer-prospects.php`). Every business is one of OUR prospects; it becomes a *lead* once we've\ntouched it (mailed, delivered a lead, or it claimed):\n- **Awareness score** (`businessDoozerAwareness`, 0-100): claim (+40), scanned (+18), delivered\n  (+6), postcards sent (+3 ea), **leads we delivered (+12 ea — the \"we give value\" driver)**,\n  live site (+4). Status hot/warm/cold/customer.\n- **Next best action + channel** (`doozerProspectAction`): getting leads + email → **email**;\n  getting leads + **NO email → mail a postcard STAT** (these float to the top — leads with no\n  inbox = money on the table); mailed already + no email → **call**; claimed+free → the Doozer+\n  pitch. **SMS is OUT (regulatory).** FB Messenger via official page = future.\n- **Projected claim %** (`doozerProjectedClaim`): a transparent heuristic today (no real\n  outcomes yet); SHARPENS as claim/convert rates by niche+market accrue from the\n  Format-performance data. The learning loop: mechanics claim 20% more than bodegas → mail more\n  mechanics.\n- The admin **shouts** it: \"Reach these N now — X mail STAT, Y email.\" Sorted urgency-first.\n- **Piped into LeadBuilder** (`doozerProspectsPipeToLeadBuilder`): each prospect becomes an\n  `lb_leads` row (source `doozer_internal`) so internal prospects live inside LeadBuilder proper.\n- Distinct from **Machi** ([[machi]]) which scores OUTBOUND NEED (\"who to mail next\"); Hot Leads\n  scores CONVERSION READINESS + the exact next move. Complementary lenses on one funnel.\n\n## Guardrails (non-negotiable)\n- No dark patterns; the free claim + anonymous browsing always work. The one-time offer is\n  honest and single-fire, no take-backs.\n- **No SMS** (regulatory). Channels: postal mail, email, phone; FB Messenger later.\n- Right offer to the right audience — always via `doozerPlusOfferFor()`, never hardcoded.\n- Minimize paid-API spend ([[feedback_api_costs]]); intelligence reads our own DB (free).\n- Money moves only on an explicit action (live mail confirm; owner-initiated checkout).\n\n## Shipped vs next\nSHIPPED (v2.2.407-419): postcard tracking + resend + view, size/format science, the\naudience-aware offer everywhere, the one-time claim offer + promo generator, Hot Leads\n(awareness + action + channel + projection) + the LeadBuilder pipe.\nNEXT: campaign/niche/market response comparison auto-weighting targeting; FB Messenger channel;\nthe **BYO-key thanks.io reseller Marketing Engine** (per-business keys via a\n`getApiKeyForBusiness()` seam, owner send-to-their-leads on `manage-business.php`, 50% affiliate\nrevenue ledger, agency tiers) — reusing the Factory + the `/frontend-design` two-score vision gate."
        },
        {
            "id": "muse-system",
            "title": "The Muse System",
            "type": "System",
            "group": "systems",
            "description": "Doozer's AI-driven design engine — palette, typography, layout, and section variants that make each business site its own.",
            "tags": [
                "design",
                "muse",
                "generation"
            ],
            "path": "systems/muse-system.md",
            "links": [
                "muse",
                "family",
                "section-framework",
                "design-standards",
                "niche"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Muse System\n\nA [Muse](../glossary/muse.md) is a design recipe (palette, typography, layout axes,\ndecorative flourishes, section variants) resolved per business and applied at render.\nIt is how one codebase produces visually distinct sites at scale.\n\n## Pieces\n\n- `includes/muse-core.php` — resolve the active Muse for a business (precedence:\n  URL override, then `businesses.custom_settings.muse_id`, then\n  `domains.custom_settings.muse_id`, then `root_domains.muse_id`). Muses live in the\n  `muses` table; the design recipe is `reading_json`.\n- `includes/muse-css-generators.php` + `includes/muse-apply.php` — turn the Muse into\n  the per-site CSS (type scale, grid system, prose width, section tones).\n- `includes/muse-contrast.php` — readability guards (AA contrast; heading-color swap\n  when the brand primary is too pale; section background rhythm).\n- `includes/muse-direction-families.php` — the 100-recipe [Family](../glossary/family.md)\n  library the auto-generator draws from, niche-filtered.\n- `includes/sections/<slug>/<variant>.php` — the [section framework](section-framework.md)\n  partials a Muse picks variants from.\n\n## CSS composition and safety layers\n\n`museEmitHeadStyles()` (`includes/muse-apply.php`) is where a Muse becomes the page's\nCSS. It composes in a deliberate ORDER, last-wins:\n\n1. Archetype CSS (the family's base look: type scale, grid, section tones).\n2. `custom_css_snippet` — the interpreter's AI-authored per-Muse flourishes, run\n   through `museSanitizeCustomCss()` first (no `@import`, no external `url()`, no\n   `</style` injection).\n3. Contrast safety (`includes/muse-contrast.php`) — AA-safe prose colors against the\n   effective backgrounds.\n4. Button-visibility safety — emitted dead-last so nothing above can undo it.\n\nBecause these are RENDER-TIME guards, fixing one heals all ~2,400 live sites with NO\nregeneration. That is the preferred way to fix a fleet-wide design fault.\n\n### Two load-bearing safety guards (both born from real bugs)\n\n- **A snippet must never redefine `--muse-*` design tokens.** The AI snippet once\n  emitted `body{--muse-font:sans-serif}`, which clobbered the correct\n  `:root{--muse-font:\"Inter\",…}` for the whole page, so every site rendered system-sans\n  regardless of its family's type pairing. Fix: `museSanitizeCustomCss()` strips\n  `--muse-font*` re-declarations (and tidies the emptied rules). Harden the interpreter\n  prompt too if snippets keep doing this. (v2.2.207)\n- **`.btn-secondary` is an outline button, so it always gets an outline.** Archetype or\n  snippet CSS with `!important` could strip its border, leaving a white fill on a white\n  header — the \"invisible Directions button.\" The final safety rule is\n  `html body .btn.btn-secondary { border: 2px solid currentColor !important; }`.\n  `currentColor` is the button's own text color, which by definition contrasts its\n  background, so the outline reads on light AND dark archetypes without forcing any palette.\n  The selector specificity (0,2,2) must EXCEED the muse button-chrome rules (flat chrome\n  emits `body[data-muse-btn=\"flat\"] .btn { border:0 !important }` at 0,2,1) or it loses even\n  with !important: the v2.2.209 rule shipped at (0,1,0) and was overridden on flat muses\n  until the bump in v2.2.213. Design-correct (secondary = outlined), not a hack. Lesson\n  worth keeping:\n  verify design by LOOKING (screenshot + inspect every CTA's contrast on a calm AND a\n  bold Muse), not by probing only the one token you changed.\n\n## Decorative flourishes\n\nSection dividers carry small SVG flourishes (`includes/muse-flourishes.php`,\n`museEmitFlourish($slot, $muse)`, called at the top of about/services/photos/reviews/faq/\ncontact in `business-site.php`). The SVG library (arched frame, sunburst, scalloped,\nbrutalist bar, risograph, hand-drawn, diamond, hairline rule) pulls its color from\n`--muse-accent`, so one SVG adapts to every palette.\n\nSelection derives a compatible POOL from signals the Muse ALWAYS carries, most specific\nfirst: an explicit `decorative_hints`/`homage_signals` keyword match wins, else the\n`template_archetype` mapped to a curated taste-safe pool, else `typography.family`, else a\nrestrained default. Pools never mix clashing vocabularies (no brutalist bar on a florist;\ncivic and editorial archetypes stay restrained). The pool is rotated by a stable per-muse\nseed (the muse id), so two muses of the same archetype can lead with different signatures\nwhile a SHARED muse renders identically across its whole root or Cloud (cohesion is the\ngoal). Per-page rhythm: the signature slots cycle up to two harmonious marks; quiet slots\n(photos/reviews/faq) get a hairline, never the same mark stamped six times.\n\nThis was a real sameness fix (v2.2.210): before it, any Muse whose AI hints did not match\nthe keyword map fell to the bland hairline on every section, and even matches repeated one\nmark down the whole page. The lesson echoes the button bug: verify by LOOKING at the\nrendered DOM, not by trusting that a signal was wired.\n\n## Hero composition\n\nv2 muses get a token-driven hero composition system (`museCssHeroComposition`, keyed by the\n`hero_composition` token: full-bleed-tint, sidebar-quote, arched-portal, marquee-strip,\netc.). v1 muses do not, so every v1 hero rendered the same centered headline over a\nfull-bleed photo — a major sameness driver, since the visible fleet is largely v1.\n\n`museHeroCompositionV1($muse)` (`includes/muse-apply.php`) gives v1 heroes a per-archetype\nLAYOUT: 'center' (default) or 'editorial' (bottom-left, ranged-left, narrower measure) for\nthe clearly-editorial archetypes. It emits `data-hero-comp` on the `.hero` section, and the\nscoped CSS in `business-site.php` changes only alignment and content measure. v2 muses\nalways return 'center' here so their own system stays authoritative.\n\nLOCK: the hero overlay (`.hero-background::before`) and the h1 / subheadline text-shadows\nare LOCKED (operator \"PERFECT!\"). This composition layer must never touch them. Bottom-\nanchored compositions are deliberately safe because they sit inside the existing bottom-\ndarken band, so legibility is unchanged. Shipped v2.2.212 as one bounded variant\n(operator: look before going wide); more compositions and a wider archetype map come after\nvisual review.\n\n## Motion as a variety axis\n\nMovement is a real source of variation within a shared Muse (shared muse across a root\nor Cloud is a FEATURE for brand cohesion, not a bug). The scroll-reveal system (fade-up\nsections, staggered cards, hero-excluded, no-JS-safe, reduced-motion-safe via\n`!important`) has two intensities, chosen per business by\n`museMotionLevel($muse, $bizTypes)`:\n\n- **calm** (default) — the gentle reveal. Professional, civic, healthcare, most niches.\n- **lively** (bold by invitation) — a stronger, snappier reveal, only when the Muse asks\n  (`reading_json.pace === 'kinetic'` or an energetic `motion_vocabulary`) OR the business\n  is an inherently energetic niche (bar, night_club, dance, gym, music, art_gallery,\n  tattoo, brewery, event_venue, …).\n\n`business-site.php` emits the lively tier behind a PHP conditional, with HARD CEILINGS\n(section travel ≤40px, ≤0.95s; cards lift ≤22px + ≤3% scale; stagger ≤0.34s) and an\nexplicit NO list (no parallax, bounce, spin, 3D, blur, zoom-past-1.0, infinite loops,\nhero motion, or horizontal drift). `prefers-reduced-motion` always wins. So \"lively\"\ncan never become tasteless or inaccessible.\n\n## Tensions to respect\n\nThe contrast/readability/width guards (the v2.0 overhaul) can flatten creativity if\nover-applied — see the creativity-regression thread in the site-quality backlog. The\ngoal is unique AND readable. Design-shape problems are governed by\n[Design Standards](design-standards.md), not one-off CSS overrides.\n\nVocabulary: [Muse](../glossary/muse.md), [Family](../glossary/family.md),\n[Niche](../glossary/niche.md)."
        },
        {
            "id": "the-muse-v2-layer",
            "title": "The Muse v2 layer, and the brief that feeds it",
            "type": "system",
            "group": "systems",
            "description": "",
            "tags": [
                "muse",
                "design",
                "rendering",
                "art-direction",
                "moma"
            ],
            "path": "systems/the-muse-v2-layer.md",
            "links": [],
            "status": "current",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Muse v2 layer, and the brief that feeds it\n\nFound by walking ONE site through the pipeline by hand: MoMA, chosen because if\nthe muse system has spirit it should show it there. Two findings, one of them\nnetwork-wide.\n\n## A muse is only as good as its brief\n\nMoMA's muse was Playfair Display with an electric-cyan accent - the default AI\nanswer for anything. The system prompt is not at fault; it is a genuinely good\nart-direction brief that insists on grounding the palette in what the business\nactually IS. The fault was the subject it was given:\n\n```\nName: The Museum of Modern Art\nGoogle types: tourist_attraction, art_gallery, art_museum, museum, ...\n```\n\n`museAutoAttachForBusiness()` selected six columns and forwarded five, while\n`_autoMuseUserMessage()` had always been able to use the address, rating, review\ncount, website, price level and scraped copy - all in the row it had just read.\n**Every bespoke muse on the network was designed from a name and a type list.**\n\nNow the brief carries all of it, plus **review text**, which had never been used\nand is the richest signal we hold: reviews name what a place is known for, in the\nwords of people who went. A MoMA reviewer writes \"whether it be Starry Night or\nmodels of schools\". No list of Google types carries that.\n\nReviews are passed as **voice and subject evidence, explicitly not as fact** -\nthey are strangers' words and some are wrong. The design brief may learn what a\nplace is famous for; the copy pipeline still takes facts from the record.\n\nResult on the same model and prompt: cyan/Playfair/editorial-magazine became\nBauhaus primaries (`#dc3a2a` / `#2a3adc` / `#f0c83a`) on bone white, bold sans,\n**poster** archetype, swiss-modular grid, \"circle square triangle\". The lineage\nMoMA canonized, from a brief that finally described the subject.\n\n**The rule: pass everything free before blaming the model.** A generic brief\ngets a generic answer, and it looks like a taste problem rather than a data one.\n\n## The v2 layer had never rendered. To any site. Ever.\n\n`museBodyAttrs()` expects a MUSE ROW and internally calls\n`museComputeTokens(museReadingWithDefaults($muse))`. Its only caller,\n`museEmitV2BodyAttrs()`, passes an already-computed TOKEN ARRAY.\n`museReadingWithDefaults()` sees no `reading_json` key and returns **pure\ndefaults, silently** - correct behaviour for a v1 muse, catastrophic here.\n\n```\npassed tokens (what shipped):  gallery=grid paper=smooth btn=flat version=\"1\"\npassed the muse row (correct): grid=swiss-modular hero-comp=layered-blocks\n                               motion=fade-up hover=invert header=minimal version=\"2\"\n```\n\n`data-muse-version` gates every v2 rule (`body[data-muse-version=\"2\"]`). So\ntype_pair, type_scale, grid_system, hero_composition, section_tones, image_crop,\nimage_overlay, motion_vocabulary, hover_style, header/footer style and\nsection_transition were all computed, written into the stylesheet, and matched\nnothing. MoMA asked for `deco-display-geo` (Limelight) and rendered Inter.\n\n**This is why bespoke sites look alike however good the reading is.** The\nreadings were never the problem.\n\n## Turning it on is its own project\n\nFixing the signature enabled v2 on 653 live sites at once - a layer that, because\nof this very bug, had never been looked at in a browser. It degraded them\ninstantly. Reverted in minutes.\n\n`museV2RenderEnabled()` now gates it: `?musev2=1` or the `muse_v2_enabled`\nplatform setting, **off by default**. Opting out reproduces the previous output\nexactly (the default token set), not just a zeroed version number - otherwise\n`data-muse-grid` / `-motion` / `-hover` would still advertise themselves to any\nrule keyed on them alone.\n\nFixed before rollout (v2.2.569): the section reveal. Nine variants each shipped\ntheir own fade-in, and three faults ran through them - scripts that queried at\nPARSE time (so they never saw sections below themselves), two variants sharing\none idempotency flag, and CSS that hid unconditionally so no-JS meant no reveal.\nNow one shared `muse-section-reveal.php`, armed by a JS-added class, collected at\nDOM-ready, with an uncancellable 4s watchdog.\n\n**A measurement caution earned here.** I first read this as \"live pages are\nblank\". It was not: sections DO reveal on genuine scroll. The blank captures came\nfrom screenshotting mid-transition and from a synthetic scroll that jumped faster\nthan IntersectionObserver samples. **When instrumenting a time-based effect, the\nobservation method is part of the experiment** - wait out the transition, scroll\nthe way a person scrolls, and prefer the DOM's own numbers to a screenshot.\n\n**`dz muse do=tokens biz=<id>`** prints every stage and the body attribute string\ncomputed both ways. Built because from outside the browser you can only see the\nresult, and every failure in this pipeline is silent by design.\n\n## The lesson that generalises\n\nA feature that has never once executed in production is not a feature, it is a\nhypothesis - and a well-tested unit can still be wired to nothing. **Ask what\nproves a layer is live, not merely that its code is correct.** Here the proof was\none attribute on one tag, and nobody had ever looked at it.\n\n## The real crawler risk, and the floor under every reveal\n\nThe page-level `.doozer-reveal section` system is scroll-driven with no watchdog.\nGooglebot executes JavaScript but does not scroll, so measured on one live site\n**7 of 10 sections were at `opacity: 0` for a rendering crawler**. Indexing beats\nany effect: both reveals now show everything unconditionally after 4s.\n\n**The rule for any effect that hides content:** it must prove it will un-hide it.\nGate the hiding on a JS-added class so no-JS never hides; collect targets at call\ntime, never at parse time; and keep a watchdog you never cancel.\n\n\n## LIVE (v2.2.578) — and what it took\n\n`muse_v2_enabled = 1`, 660 sites. **`dz muse do=v2 on=0` is the instant rollback**, no\ndeploy. It was used once during the release and worked in seconds.\n\n`muse-motion.js` gates on `data-muse-version=\"2\"`, so it had never executed in\nproduction either. Switching v2 on ran it for the first time and exposed three\ndefects, all the same shape as the section-reveal bugs:\n\n- **`initFadeUp` wrote INLINE `opacity: 0`.** Inline beats every stylesheet rule, so\n  sections carrying both reveal classes still sat at zero. It also matched NESTED\n  `[data-muse-section]` nodes, which can never intersect inside a hidden parent. Now it\n  defers completely when the page-level reveal exists — **one reveal per page** — and\n  targets top-level sections only, with an uncancellable watchdog.\n- **`initTypewriter` destroyed the H1.** `textContent = ''` then retyped it over ~3s.\n  Googlebot executes JS, so a crawler could snapshot an empty or partial headline; a\n  stall left it truncated for good. Now the full text stays in the DOM and is revealed\n  with a clip.\n- **`initTiltWake` hid gallery cards** with no watchdog.\n\n## Measuring a time-driven effect: the tab is part of the experiment\n\nTwice during this release a page read as \"10 of 11 sections hidden\" and then healed the\nmoment I ran JavaScript against it. Cause: **a backgrounded browser tab throttles timers\nand does not fire IntersectionObserver** — and running JS wakes it.\n\nCheck `document.visibilityState` and `document.hasFocus()` before believing any\nmeasurement of a scroll- or timer-driven effect. Foreground the tab first. Screenshots\nof a throttled tab are stale for the same reason.\n\nTogether with the earlier mistake (screenshotting mid-transition, scrolling faster than\nIO samples), that is three separate occasions where **the observation method was the\nbug**. Prefer the DOM's own state — is the reveal class present? — over a rendered\npixel, and when the two disagree, suspect the harness."
        },
        {
            "id": "the-network",
            "title": "The Network (the whole Doozer ecosystem)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "ecosystem",
                "network",
                "products",
                "referral-loop",
                "breadth"
            ],
            "path": "systems/the-network.md",
            "links": [
                "architecture",
                "two-tier-model",
                "site-creation",
                "elks-root-theme",
                "nearbymenow",
                "clouds",
                "livemusictonight",
                "lead-capture",
                "location-data-enrichment",
                "prospector",
                "marketing",
                "magellan-id-mapping",
                "the-value-model",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Network\n\nDoozer is not one website. It is a **network of surfaces** that share one engine,\none data spine, and one referral loop. `renderNetworkSitesBar()`\n(`includes/network-footer.php`) is the single source of ecosystem naming/order; a\n`soon` flag flips a product live. Everything below is built by BerrySmart (Jon\nBerry, solo founder) on the same [architecture](architecture.md).\n\n## The surfaces\n\n- **Doozer** (`doozer.work`) — the platform: landing page + admin console + the\n  engine that generates every business site. Free tier = a templated info page the\n  business owns; paid **Doozer+** = a bespoke full site. See\n  [the two-tier model](../decisions/two-tier-model.md).\n- **Business sites** — the leaves. One generated single-page site per business,\n  served on a [root](../processes/site-creation.md) subdomain (e.g.\n  `*.elks.club`, `*.westmilford.biz`, `*.vsso.net`).\n- **Roots** — a wildcard domain whose apex is a directory of its member businesses,\n  optionally with a bespoke identity layer (see the [Elks root theme](elks-root-theme.md)).\n- **NearByMeNow** (`nearbymenow.com`) — the [directory/traffic layer](nearbymenow.md):\n  a public \"what's near me\" surface that indexes the whole fleet and feeds visitors\n  back to business sites. The active side of the referral loop.\n- **Clouds** (`doozer.cloud`, `my.doozer.cloud`) — [curated directories](clouds.md)\n  by niche/region/criteria, plus each user's personal cloud.\n- **Vertical Directories** — niche directories built on shared data, e.g.\n  [livemusictonight.com](livemusictonight.md).\n- **LeadBuilder** — the [lead-intelligence](lead-capture.md) destination; every\n  captured lead and intent signal flows here to become revenue.\n- **GoHe.re** (gatherings/events), **AwesomeAddresses** (homes), **wuz.ai**\n  (check-ins) — [roadmap surfaces](location-data-enrichment.md) on the same spine.\n\n## The loop (why it compounds)\n\nBusiness sites are found via search + NBMN + Clouds → visitors act (call, message,\nvisit) → those actions become [leads and signals](lead-capture.md) → signals drive\n[Prospector](prospector.md) discovery and [Marketing](marketing.md) → more sites →\nmore directory depth → more traffic. Cross-surface identity is always by ID via\n[Magellan](../decisions/magellan-id-mapping.md), never by name-guessing. The\neconomics of this loop are [the value model](../decisions/the-value-model.md); the\nmechanics are [the flywheel](the-flywheel.md)."
        },
        {
            "id": "niche-color-system",
            "title": "The Niche Color System",
            "type": "System",
            "group": "systems",
            "description": "One source of category color for the whole network — the design primitive that makes cards, pills, spines, and markers coherent across every surface.",
            "tags": [
                "color",
                "niche",
                "category",
                "design",
                "primitive"
            ],
            "path": "systems/niche-color-system.md",
            "links": [
                "shared-business-card",
                "the-network",
                "design-standards",
                "network-growth"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Niche Color System\n\n`nicheColor()` (`includes/niche-colors.php`) is the single source of category\ncolor network-wide. Category → hue, niche → shade within that hue. Every surface\nthat shows a business or a niche — the [shared business card](shared-business-card.md)\n(spine + category pill), directory chips, map markers, category pages — draws from\nthis ONE function, so a \"food\" place looks like food everywhere and an\n\"automotive\" place looks like automotive everywhere, without per-surface color\nlogic.\n\n## Why it's a primitive, not a detail\n\nCoherent category color is what makes the [network](the-network.md) feel like one\nsystem instead of many pages. It is a design PRIMITIVE (like the\n[design standards](design-standards.md) registry and the shared card): change the\nmapping in one place and the whole network updates. The star rating uses a fixed\ngold (`#f5a623`) so ratings read consistently regardless of category.\n\nConsuming code destructures `[$accent, $soft]` (a strong accent + a soft tint for\nbackgrounds/pills). New surfaces that display a niche must call `nicheColor()`\nrather than inventing a palette — that is how a new card, a new directory view, or\nthe \"More places nearby\" cards ([network growth](network-growth.md)) stay on-system\nfor free. Next horizons: category-page theming and colored map markers driven by\nthe same source."
        },
        {
            "id": "photo-pipeline",
            "title": "The Photo Pipeline (harvest, store, serve)",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "photos",
                "images",
                "cache",
                "spaces",
                "cdn",
                "cost",
                "places"
            ],
            "path": "systems/photo-pipeline.md",
            "links": [
                "architecture",
                "source-of-truth-cache",
                "the-observed-world"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Photo Pipeline\n\nOne sentence: **we store photo *references* for free, buy the bytes once when a real\nperson first looks, keep them forever as public objects, and every URL is derived from\na key we can predict — so any page can ask \"do we already own this?\" before spending.**\n\nRelated: [Architecture](architecture.md) · [Source of Truth Cache](source-of-truth-cache.md) · [The Observed World](../decisions/the-observed-world.md)\n\n---\n\n## 1. Harvest — we take the reference, not the picture\n\nWhen a business is discovered, Google Places returns photo handles. Those land in\n`businesses.photos` (JSON) in one of two shapes:\n\n| API | field | looks like |\n|---|---|---|\n| Places API (New) | `name` | `places/ChIJ…/photos/AaVGc3n…` |\n| Places API (Old) | `photo_reference` | `AaVGc3n…` |\n\n**Nothing is downloaded at this point.** A reference costs nothing to hold, and most\nbusinesses in the network are never opened by anybody. This is what makes par-created\nskeletons and the directory's blueprint plates cheap: we know a place has photographs\nwithout having paid for them.\n\n## 2. Choose — which photo to show\n\n`resolveBusinessImageUrl($row, $width)` in `business-image-resolver.php` is the\n**canonical picker**, used by every surface so quality matches everywhere. It returns\n`['url' => …, 'is_logo' => bool]`, preferring, in order: an already-resolved\n`photo_url`, then Google photos, then scraped / deep-research images, then the logo.\nWhen it returns nothing the caller draws a category tile instead.\n\n`business_photo_meta` (**58,545 rows**) holds per-photo vision analysis — subject\nclass, aesthetic, quality, caption — for ranking and hero selection.\n\n## 3. Derive the URL — never link Google directly\n\n`googlePlacePhotoProxyUrl($photo, $maxWidth)` builds a URL on our own host:\n\n```\nhttps://<any-doozer-host>/?e=photo&name=<urlencoded reference>&w=<W>&h=<H>\n```\n\nLinking `googleapis.com` directly would leak the API key and bill us for every view by\nevery visitor and every bot. The proxy is what makes the rest of this possible.\n\n## 4. The cache key — the load-bearing detail\n\n```php\n$cacheFilename = generateCacheFilename($cacheKey, 'google', ['w' => $w, 'h' => $h]);\n// google_<md5( photoName . json_encode(['w'=>W,'h'=>H]) )>.jpg\n```\n\n**The width and height are part of the key.** A size nobody has requested before is a\nguaranteed cache miss and a fresh purchase — and under warm-only rendering it means the\nphoto silently never appears, no matter how many copies of that same image we already\nown at other sizes.\n\nReuse the sizes the network already accumulates:\n\n| use | width |\n|---|---|\n| directory + nearby thumbnails | **400** |\n| business cards | 600 |\n| hero | 1200 |\n\n## 5. Serve — the order `?e=photo` tries\n\n1. **Registry first.** `imageRegistryLookup($cacheFilename)`; if `status = 'cdn'`,\n   302 straight to `cdn_url`. No local file needed. Gated by the platform setting\n   `image_serve_from_cdn` (currently `1`).\n2. **Local disk cache** — `cache/images/<cache_key>` (path in `image_cache_path`).\n3. **Negative cache** — `neg_<cache_key>`. Stale Google references return HTTP 400\n   forever; the marker stops us re-buying them on every pageview.\n   *Ordering matters: the positive cache is checked first, or one transient 4xx hides a\n   perfectly good image behind a negative marker.*\n4. **Fetch from Google** — New API `places.googleapis.com/v1/<name>/media`, or Old API\n   `maps.googleapis.com/maps/api/place/photo`.\n5. **Determine the content type by SNIFFING MAGIC BYTES**, not by trusting the upstream\n   header (see gotchas).\n6. **Serve the visitor, then persist.** After `fastcgi_finish_request()` — so the\n   visitor waits for none of it — the bytes are written to Spaces and recorded in the\n   registry. Gated by `image_spaces_dualwrite`.\n\n## 6. Store — objects on DO Spaces\n\n- Bucket, region and CDN base live in `platform_settings`: `spaces_bucket`,\n  `spaces_cdn_base`. Credentials are `spaces_key` / `spaces_secret` in `api_keys`.\n- Object key: **`images/<cache_key>`**\n- Written `public-read` with `Cache-Control: public, max-age=31536000, immutable`\n- Public URL: `<spaces_cdn_base>/images/<cache_key>`\n\n## 7. The registry — `image_assets`\n\n**128,168 rows: 113,506 on the CDN, 14,662 negative.**\n\n| column | meaning |\n|---|---|\n| `cache_key` | `google_<md5>.jpg` — the join key for everything |\n| `spaces_key` | `images/<cache_key>` |\n| `cdn_url` | the public URL |\n| `content_type` | what the object is served as |\n| `bytes`, `source_type` | size and origin |\n| `status` | `cdn` (we own it) / `negative` (known-bad reference) |\n| `cdn_uploaded_at`, `ct_fixed_at` | when stored, when its type was repaired |\n\n## 8. Knowing what we already own — the warm-only rule\n\nBecause the key is predictable from the URL alone, a page can ask about a whole screen\nof photos in one query before rendering any of them:\n\n```php\ndzcPhotoCacheKey($url)          // reverse the URL back to its cache key\ndzcPrimePhotoWarmth([$urls])    // ONE batched lookup for the whole page\ndzcPhotoIsWarm($url)            // free thereafter\n```\n\nPhotos we own are shown; photos we do not are replaced by the category plate, and the\nfirst real visit to that business buys them for everyone after. Used by the NBMN\ndirectory, the NBMN home list, the neighborhood block on every business site, and the\nJSON-LD emitter.\n\n**It fails open**: if the registry is unreachable everything is treated as warm. A\ndirectory that silently loses its imagery to a database hiccup is a worse failure than\none that spends a little.\n\n## 9. Gotchas, all of them paid for\n\n1. **Width is in the key.** Asking for 160px to fill a 44px tile meant every card fell\n   back to a plate while its photo sat on the CDN at 400.\n2. **Never trust the upstream `Content-Type`.** Read the *last* header across redirects\n   and sniff the magic bytes. **32,223 objects were stored as `application/json`** —\n   valid JPEGs that browsers refused to render, showing blank boxes across the network\n   for months.\n3. **Correcting an object does not correct the cached response.** The CDN edge had\n   already cached the bad header under `immutable, max-age=31536000`. Healing the object\n   changes nothing a browser sees. The registry-first 302 now appends\n   `?v=<hash of ct_fixed_at>` for healed objects only — a new edge cache key exactly\n   once, leaving every never-healed image on its warm URL.\n4. **Structured data must publish warm images only.** `?e=photo` buys on first request\n   and 83% of network traffic is bots; a cold photo URL in JSON-LD is an invitation for\n   crawlers to spend money on pictures no person asked to see.\n5. **A stamped row is not a fixed row.** An early version of the healer stamped\n   `ct_fixed_at` without correcting `content_type`; those rows looked done and nothing\n   retried them. `dz imgct reset=1` clears the stamp.\n\n## 10. Operator commands\n\n```\ndz imgct [limit=] [rounds=] [all=1] [probe=1] [reset=1]   # repair content types\ndz photo_audit                                            # find broken/missing images\ndz photo_heal                                             # re-resolve and re-upload\n```"
        },
        {
            "id": "section-framework",
            "title": "The Section Framework",
            "type": "System",
            "group": "systems",
            "description": "How a business page is assembled from pipeline section slots and per-variant partials a Muse selects.",
            "tags": [
                "render",
                "sections",
                "muse"
            ],
            "path": "systems/section-framework.md",
            "links": [
                "muse",
                "design-standards"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Section Framework\n\nA business page (`includes/business-site.php`) is a sequence of section slots (hero,\nabout, services, why-choose-us, menu, products, photos, reviews, faq, hours, contact).\nFor each slot the active [Muse](../glossary/muse.md) may pick a variant; otherwise a\nclassic fallback renders.\n\n- `includes/muse-sections.php` — `museRenderSection($slug, $variant, ...)` dispatches to\n  a partial at `includes/sections/<slug>/<variant>.php`, with a taste-defense gate that\n  downgrades to `classic` when a variant's prerequisites are not met.\n- Variants share container classes (`.services-intro`, `.about-content`,\n  `.muse-alt-prose`) so global prose rules apply across all of them.\n- All prose flows through `renderProse()` (`includes/prose-render.php`), which enforces\n  [Design Standards](design-standards.md).\n\nThe roadmap (per the grand vision) is per-business-type sections: restaurants get\nmenus and daily specials, venues get calendars and commerce. New section types become\nnew slots + partials here."
        },
        {
            "id": "shared-business-card",
            "title": "The Shared Business Card",
            "type": "System",
            "group": "systems",
            "description": "The one card renderer used across NBMN, cloud, and root directories, with a pluggable toolbar and category-aware call intent.",
            "tags": [
                "directory",
                "card",
                "ui"
            ],
            "path": "systems/shared-business-card.md",
            "links": [
                "cloud",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Shared Business Card\n\n`includes/business-card.php` is the ONE card renderer network-wide (it replaced four\ndrifted implementations). `businessCardData($row)` normalizes any listing shape;\n`renderBusinessCard($card, $opts)` emits a CSS-self-contained `dzc-*` card that works on\nTailwind root pages and vanilla NBMN alike; `renderBusinessCardAssets()` ships the\nstyles + the one canonical image fallback + the call popup.\n\n## Key properties\n\n- Image via the canonical `resolveBusinessImageUrl()` everywhere (killed the NBMN blank\n  image bug). The card self-loads its dependencies (image resolver, niche helpers,\n  call-intent catalog) because directory surfaces do not preload them.\n- Pluggable toolbar `renderBusinessCardToolbar` — default actions Call + Save; check-in\n  is contextual (map/near-me), not on generic directory cards.\n- Call uses category-aware [call intent](#call-intent).\n- Icons are neutral white on the dark translucent circle; COLOR is reserved for STATE\n  (saved heart red, etc.), never for the buttons.\n\nRendered network-wide: NBMN, [Cloud](../glossary/cloud.md), and root directories.\n\n## Call intent\n\n`includes/call-intent-categories.php` maps a business's category/niche to a small set\nof intent chips (restaurant to \"Order takeout\"; flooring to \"Get a flooring quote\").\nThe same catalog powers the card Call popup and the business-site Call Now popup.\nRecords to `lead_intents` and bridges to LeadBuilder. See [the flywheel](the-flywheel.md)."
        },
        {
            "id": "weather-widget",
            "title": "The Weather Widget",
            "type": "System",
            "group": "systems",
            "description": "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.",
            "tags": [
                "weather",
                "business-site",
                "enrichment",
                "visit-timing",
                "open-meteo"
            ],
            "path": "systems/weather-widget.md",
            "links": [
                "location-data-enrichment",
                "the-521-root-cause",
                "muse-system",
                "icon-taste-rule",
                "the-value-model",
                "gatherings-and-social"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The Weather Widget\n\nEvery Doozer business site can show a small, calm weather card near its Hours\nsection: **current conditions plus a 7-day forecast for the business's own\nlocation.** It is a helpful visit-timing signal — \"is this a good day to go?\" —\nand never a popup or an interruption. It is part of the broader push to make a\nsite more than a static listing (see\n[per-location data enrichment](location-data-enrichment.md)).\n\n## How it works\n\n`includes/weather.php` is the whole engine:\n\n- `weatherFetch($lat, $lng)` is **cache-only and never blocks the render.** On a\n  cache hit it returns the normalized shape (`current` + a 7-day `daily` array)\n  regardless of provider. On a miss it writes an empty dedupe marker, returns\n  `null` (no card this paint), and hands the real HTTP call to\n  `weatherScheduleWarm()`, which runs it *after* the response via\n  `register_shutdown_function` + `fastcgi_finish_request()`. The next visitor\n  inside the 2h TTL gets the warmed value. This is deliberate: the live\n  Open-Meteo/OWM call once ran inside the render (`weather.php:82`) and the FPM\n  slow log named it the top request-holder and a [521](the-521-root-cause.md)\n  amplifier. A third-party weather API can never stall a page again (v2.2.494).\n- `renderWeatherWidget($ctx)` renders the card: current temperature + condition,\n  an optional place label (the city, parsed from `formatted_address`), and a\n  7-day strip of day / icon / high·low. Its CSS is scoped and\n  `!important`-hardened so arbitrary [muse](muse-system.md) rules cannot restyle\n  it (the same chrome-hardening rule the sponsor bar and dialogs follow).\n- `weatherEnabled()` gates the whole thing on a platform setting.\n\n`includes/business-site.php` renders it right after the Hours section, deriving\ncoordinates from the same geometry / lat-lng fallback the map uses. It renders\nnothing at all when the widget is disabled, coordinates are missing, or the fetch\nreturns no data — a missing card is always better than a broken one.\n\n## Provider and cost\n\nThe default provider is **Open-Meteo: free, no API key, worldwide.** WMO weather\ncodes map to calm inline-SVG glyphs (no emoji clipart, per the\n[icon taste rule](../decisions/icon-taste-rule.md)). Results cache for about two\nhours per ~1 km location bucket, so the only work is a cache miss and the running\ncost is zero — consistent with the platform's\n[cache-aggressively, minimize-API-cost discipline](../decisions/the-value-model.md).\n\nAn operator can optionally set an **OpenWeatherMap** key (Admin → Platform\nSettings → Weather) to use that provider instead; the code aggregates OWM's free\n5-day/3-hourly feed into the same daily shape. No key is required for the widget\nto work.\n\n## Admin\n\nAdmin → Platform Settings → **Weather** tab: an enable toggle (on by default) and\nthe optional OpenWeatherMap key field, with a plain-language note that a key is\nnot needed. Stored in `platform_settings` under category `weather`.\n\n## Roadmap\n\nThis shipped from the roadmap idea of a visit-timing signal. The further vision:\nfeed weather into [Gatherings](gatherings-and-social.md) so any place + time can\nanswer \"what will it be like when I'm there,\" and surface it as a signal in the\nevents/marquee surfaces. See [[project_weather_widget]]."
        },
        {
            "id": "two-gates-and-the-catalog",
            "title": "Two gates, and the category catalog",
            "type": "system",
            "group": "systems",
            "description": "",
            "tags": [
                "nearbymenow",
                "par-create",
                "visibility",
                "niche",
                "seo",
                "directory"
            ],
            "path": "systems/two-gates-and-the-catalog.md",
            "links": [],
            "status": "current",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Two gates, and the category catalog\n\n## One flag was answering two questions\n\n`domains.include_in_sitemap` gated the sitemap AND every directory listing query.\nSo \"do not submit this to Google\" and \"do not show this to a person browsing\nNearByMeNow\" were the same switch. There is no setting for the state par-create\nwas designed to produce: a real site, worth showing to the human who conjured\nit, not yet worth asking Google to index.\n\nNow:\n\n| flag | question | posture |\n|---|---|---|\n| `include_in_directory` | can a person find this in NBMN? | **generous** - real content about a real, located place |\n| `include_in_sitemap` | do we ask Google to index it? | **conservative** - thin content stays out |\n\n`siteDirectoryGateSql($alias)` is the fragment every listing query uses; it falls\nback to the old flag until the migration has run, so there is no broken window.\n`sitemap.php` and `indexnow.php` stay on `include_in_sitemap` deliberately - they\nare the index side.\n\n**A new skeleton must close both gates explicitly.** `include_in_directory`\ndefaults to 1 so that thousands of existing rows keep working; a skeleton with no\ncontent would therefore list itself. `par-create.php` writes 0,0.\n\n## The promotion step did not exist\n\nThe rule \"skeletons are promoted on enrichment\" was written down, taught to every\nsession, and never implemented. Par-create inserted `include_in_sitemap = 0` and\nthe only statement anywhere that set it back to 1 was a manual bulk action in the\nadmin domain list. 662 sites were enriched (for $1.11, in one batch) and stayed\ninvisible.\n\n`sitePromoteOnEnrichment($businessId)` now runs from `saveGeneratedContent()` -\nthe single place all generated content is written. Directory visibility is earned\nby having substantive content; index eligibility is a separate, stricter test\n(`siteIndexEligible`: real prose + an image + an address + coordinates).\n\n**A documented rule with no code behind it is indistinguishable from a bug, and\nharder to find, because everyone believes it already works.**\n\n## The catalog: 57% of the network had no page\n\n1,658 of 2,929 listable businesses appeared on no niche page. Not bad data - the\n`niche_mappings` table simply had no row for the type they carry, so no page\nexisted to put them on. The largest group was `association_or_organization` at\n1,627: **the lodges, which drive 68% of human traffic, had no category.**\n\n`dz nichecatalog [seed=1 [live=1]]` is the seeder; `dz nichecatalog` alone is the\nstanding gap report. Two kinds of entry, and the distinction matters:\n\n- **New niches** - a real category with no page (associations, event-venues,\n  live-music, theaters, stadiums, golf-courses, sports-clubs, breweries,\n  wineries, caterers, medical-clinics, chiropractors, nonprofits, farms).\n- **Aliases** - a Google type that is a sub-kind of a niche we already have.\n  `mexican_restaurant` is a restaurant. It should not get its own page; it\n  should be findable on the restaurants page and rank there as an exact match.\n\nOrphans went **1,658 -> 4**.\n\nGoogle's broadest tags (`service`, `consultant`, `health`, `finance`) route to\nthe catch-all `businesses` page - a page beats being unreachable, and a wrong\ncategory would be worse than a vague one. `locality` and `food` are excluded on\npurpose: one is a town, the other is a modifier.\n\n## Keeping new sites categorized\n\n`backfillBusinessFlatColumns()` chose the first non-generic Google type, so\n`['consultant','accounting','finance']` filed under `consultant` - no page - while\n`accounting` sat right behind it. It now prefers **the earliest type that resolves\nto a known niche**, falling back to the old behaviour so a genuinely new category\nis still recorded rather than dropped. Anything unmapped surfaces in\n`dz nichecatalog` instead of vanishing.\n\n## Gotcha: a probe guard that ate real niches\n\nThe infrastructure-probe denylist (v2.2.563) treated `^[a-z0-9]{12,}$` as a\ngenerated hash. That matches any all-letter word of twelve characters or more, so\n`associations` (exactly 12), `chiropractors`, `veterinarians`, `electricians`,\n`supermarkets` and `universities` all 404'd on their own subdomains. A real hash\nmixes in digits; a niche slug never does. **A guard written against one shape of\ngarbage will catch real data if the shape is described loosely enough** - and it\nfails silently, because a 404 on a page nobody has linked yet looks like nothing."
        },
        {
            "id": "event-feeds",
            "title": "event-feeds",
            "type": "Systems",
            "group": "systems",
            "description": "",
            "tags": [],
            "path": "systems/event-feeds.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Event Feeds — Marquee and the multi-source engine\n\n**What:** the pattern that builds the network BACKWARDS from ticketed/scheduled\nevents. An external feed lists events at venues; each venue becomes a Google-Places-\nbacked Doozer site, enriched with its upcoming events, turned into a Global Gathering,\nwith affiliate ticket links. Marquee (Ticketmaster) is the first live instance; the\nplan is to generalize it into one engine with per-source adapters.\n\n**The pipeline (source-agnostic after the fetch):**\nfeed fetch -> normalize -> match venue to an existing business (accuracy-first) OR\nbuild it (`resolvePlaceToEcosystem`) -> stamp the external venue id (Magellan link)\n-> `business-events.php` renders events on the site -> gathering + affiliate link.\n\n**The universal join key is the Google place_id.** Every feed's venue resolves to one,\nso multiple feeds enrich the SAME site instead of duplicating. This is why the\naccuracy-first matcher matters as feeds multiply.\n\n**Marquee today (`includes/marquee-engine.php`, `admin-marquee.php`):**\n- `marqueeTmFetch` (Discovery API v2), `marqueeNormalizeTm`, `marqueeIngest`.\n- `marqueeMatchVenue($name,$city,$state,$lat,$lng,$tmVenueId)` — accuracy-first\n  (v2.2.433): tm_venue_id -> physical geo (name-confirmed, never geo-only, generic\n  venue words excluded) -> normalized name -> legacy. Unit-tested 7/7 on real\n  collisions. Ingest stamps `businesses.tm_venue_id` on every match (enriches existing\n  sites, no rebuild).\n- **Trending (US)** (v2.2.435): national sweep, Ticketmaster relevance order\n  (`sort=relevance,desc`), no city. Skims the hottest venues everywhere; widen into\n  quieter markets over time.\n- **Discovered venues** card = the build list (`marqueeRecentVenues`). Was silently\n  empty until v2.2.431 (an `ORDER BY g.site_domain` alias bug — a correlated-subquery\n  alias can't be table-qualified in ORDER BY).\n- `dz marquee [do=status|repair|backfill|trending]`. `repair` re-links orphans;\n  `backfill` re-runs the matcher over stored events.\n\n**Business-first enrichment (v2.2.438) — the complement to feed-first:** a venue site\nbuilt OUTSIDE Marquee (More Places Nearby / Prospector) has no events, because Marquee is\nfeed-first (events arrive only when a sweep covers that venue). `marqueeEnrichBusiness($bizId)`\nprobes the feeds BY the business's own name + coordinates and links its shows to the existing\nsite. Reuses everything (marqueeIngest keyword+geo → accuracy match → stamp → site renders);\n1 TM call (+1 SeatGeek) per business, 3-day freshness guard. `marqueeEnrichScan($limit)` =\nniche-gated backfill of venue sites that have a site but no upcoming events. `dz marquee\ndo=enrich biz=<id>` / `do=enrich-scan [limit=]`. THE PRINCIPLE: every enrichment feed is a\nbusiness-first probe keyed on identity (name+geo+niche) whose results route to the right\nsection; Marquee/events is the first.\n\n**GOTCHAS (learned the hard way):**\n- **Apostrophe orphans (v2.2.434):** a build created the business + marked\n  `pr_discoveries.status='site_created'` but the domain never persisted, because\n  `linkDomainToBusiness()` returned true without verifying the INSERT. Every rebuild\n  then dead-ended on the \"already created\" guard. Fix: the link now VERIFIES the row +\n  logs the real error; the guard SELF-HEALS (only blocks when a live domain exists).\n- Ingest silently swallowed per-row insert errors -> \"Ingested 0 of N\" with no reason;\n  now surfaces skip count + first error and does not auto-reload over a zero-store.\n\n**Multi-source (v2.2.436):** `marqueeIngest` takes `opts['source']` (ticketmaster |\nseatgeek) and dispatches the adapter; `marquee_events.source` + `marquee_sweeps.source`\nare bound, not hardcoded. Cross-feed dedup is by Google place_id (a venue built from TM\nis ENRICHED by SeatGeek, never duplicated).\n\n**SeatGeek — feed #2, SHIPPED (`includes/seatgeek.php`):** the affiliate revenue play\n(~$11/sale vs TM's thin margins). Pure adapter: `seatGeekFetch`/`seatGeekNormalize`\nemit the same flat shape as `marqueeNormalizeTm`. Key = `seatgeek` client_id in api_keys\n(free at seatgeek.com/build). Affiliate `aid` in `platform_settings seatgeek.affiliate_id`,\nwrapped onto ticket urls when set (blank = links go straight to SeatGeek). Its native\n`score` is the trending order (`sort=score.desc`). Admin -> Marquee has a SeatGeek card +\na Source selector on Ingest/Trending. `dz seatgeek [do=test|trending|ingest]`.\n\n**Generalization plan:** see `docs/features/EVENT_FEEDS_ROADMAP.md`. FeedSource\ninterface; Ticketmaster + SeatGeek are adapters #1/#2. **Facebook events are DEAD**\n(no compliant API). **Movies (Popcorn)** = theaters from Places + Fandango affiliate\ndeep-links now; paid showtimes feed only on demand. A generic **ICS/RSS adapter** is\nthe on-brand long-tail win (civic/library/campus calendars -> gatherings). Keep it ONE\nengine with adapters, not forked per vertical.\n\nLinks: [[project_marquee_status]], the Observed World, GoHe.re gatherings,\n[[project_monetization_plan]] (per-source affiliate ledger)."
        },
        {
            "id": "machi",
            "title": "machi",
            "type": "Systems",
            "group": "systems",
            "description": "",
            "tags": [],
            "path": "systems/machi.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Machi — the marketing brain\n\n**What:** the single strategist every outbound marketing touch flows through. She\nreads the observed world, decides WHO needs a postcard, picks the MESSAGE most\nlikely to convert them, has the AI postcard engine paint a gated card, and learns\nfrom what actually happened (scans / visits / claims / Doozer+ conversions).\nWinning angles get sent more; losers fade. Named for a strategist who \"smells\nsuccess in the wind.\" Shipped v2.2.380.\n\n**Engine — `includes/machi.php`**\n- `machiScoreBusiness($biz)` → 0-100. NEED (weak/no own website), FIT\n  (`machiNicheTier` — trades/health/auto/legal/realtor = tier 3, active marketers\n  food/beauty/fitness = tier 2), PROOF + ability to pay (review volume, rating,\n  price_level). Returns score + segment + reasons.\n- `machiRankTargets($opts)` → the \"these need a postcard\" list. SQL preconditions:\n  active domain (a real screenshot exists), OPERATIONAL, not claimed\n  (`admin_email` empty), not already converted; default view = untouched, or\n  `follow_up` for businesses past cooldown (≤3 touches). Scored + sorted in PHP.\n- `machiPickVariant($businessId,$seg,$peek,$exclude)` → the angle. Weighted-random\n  by `machiVariantScore` (Bayesian-smoothed conversion value + scan/claim signal) ×\n  segment affinity, EXCLUDING angles already SENT to this business (follow-ups\n  differ) and any explicit exclude (a rerun's current angle).\n- `machiGenerateForTarget($businessId,$opts)` → pick angle → `postcardAiRun` with\n  `campaign='machi'`, `message`=chosen copy, `variant_id`. NO touch yet (previews\n  and reruns must not inflate the sequence); the angle is stored on the design.\n- `machiFullSend($designId,$opts['live'])` → `postcardAiSend` live, then creates the\n  `machi_touches` row (the send is the real event), touch_no = prior sent + 1,\n  then `machiSyncOutcomes`.\n- `machiSyncOutcomes()` → for each SENT touch pull `postcardAiTracking` + claim +\n  `machiBusinessConverted` (premium owner by email match), recompute each\n  variant's rolling stats from `machi_touches` (idempotent).\n- `machiPerformance()` (funnel + variant leaderboard + spend from\n  `ai_generation_history` content_type `postcard_ai` + thanksio sends + revenue =\n  conversions × `machi_conversion_value` default 99 + ROI). `machiMarketScan()` =\n  which segments pay back.\n\n**Tables (self-creating):** `machi_variants` (8 seeded angles: ready / leads /\nfound / free / proof / local / pens / ahead; each with rolling\ntimes_sent/scans/visits/claims/conversions) and `machi_touches` (per-business send\nhistory + learning signal). `postcard_designs` gained `variant_id` + `angle`.\n\n**Dashboard — `includes/admin-machi.php`** (Story → Machi, route `machi`): funnel +\nspend + revenue + ROI; ranked target cards (score / why / segment / lead angle) →\nGenerate → gated preview appears in-card → **Full send** (live=mail) / **Rerun**\n(fresh angle) / Reject; \"Winning messages\" leaderboard + \"Market scan\"; filters by\nsegment / state / min score / follow-ups-due / untouched-only.\n\n**Creative uses the operator's Image Generation config** now: `postcardAiGenerate`\nroutes through `aiImageProviderDefault()` — OpenAI GPT Image via `/images/edits`\n(screenshot + logo as real reference images) OR Gemini generateContent — so no\nseparate key is needed when OpenAI is set, and cost lands in Analytics & Costs at\nthe configured per-image price. See [[ai-postcards]], [[claim-codes]],\n[[thanksio]], [[the-observed-world]].\n\n**Dev API:** `dz machi` — `rank` / `gen b=` / `send id= live=1 confirm=1` / `sync`\n/ `perf` / `market` / `seed`.\n\n**Verified live v2.2.380:** seed + rank return sensible targets (established\nhigh-review businesses first, angles varied, trades score tier-3). Real sends +\nlearning accrue once postcards mail. NEXT: scheduled auto-drafting of top targets\ninto the review queue (traffic-driven, like the job runner); agent-authored new\nangles into `machi_variants` (source='agent'); market/geo selection surfacing\nhigh-ad-spend metros."
        },
        {
            "id": "category-aware-call-intent",
            "title": "Category-aware call intent",
            "type": "System",
            "group": "craft",
            "description": "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.",
            "tags": [
                "craft",
                "call-intent",
                "lead-capture",
                "chrome-primitive",
                "categories"
            ],
            "path": "craft/category-aware-call-intent.md",
            "links": [
                "niche-color-system",
                "leadbuilder",
                "shared-business-card",
                "lead-capture",
                "fix-the-source-not-the-symptom"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Category-aware call intent\n\nWhen a visitor taps **Call** on a Doozer site, most sites would just dial the number.\nThat is a wasted moment. The reason a person calls a business is *specific to what the\nbusiness is*, and that reason is a lead signal worth capturing without adding a single\nstep of friction.\n\nSo the call action opens a tiny set of **intent chips** — and the chips are different\nfor every kind of business, because the reasons to call are different:\n\n- A **restaurant**: \"Order takeout\" · \"Reserve a table\" · \"Today's hours\"\n- A **plumber** (services): \"Get a quote\" · \"Book a visit\" · \"Is this urgent?\"\n- An **auto shop**: \"Get a repair estimate\" · \"Book service\" · \"Is my car ready?\"\n- A **salon** (beauty): \"Book an appointment\" · \"Any openings today?\" · \"Ask about pricing\"\n- A **doctor** (health): \"Book an appointment\" · \"I'm a new patient\" · \"Ask about insurance\"\n\nNobody has to think about which chip fits; the right three are already there. The\nvisitor taps the one that matches their head, then the call connects. We learn *why*\nthey called before they say a word.\n\n## Why this is the exemplar\n\nIt is the pattern we want more of: a **chrome primitive** (a small, shared piece of\ninterface) that is *aware of the thing it is attached to.* The same tap, on two\ndifferent businesses, produces two different, perfectly-fitted experiences, and each\ntap is a typed lead signal. It costs the visitor nothing and it makes the site feel\nlike it understands the business. That is the whole Doozer thesis in one control.\n\n## How it resolves (niche wins, category backs it up)\n\n`includes/call-intent-categories.php` has two layers. `callIntentChipsForNiche()`\nchecks for an override keyed to the specific niche (pizza, roofers, coffee shops have\ntheir own hand-tuned chips). If there is no niche override, it falls back to\n`callIntentChipsForCategory()` for the broad category (food, services, health, beauty,\nautomotive, and so on), and finally to a sane general set. So a niche can be as\nspecial as it deserves to be, and everything still has a good default. This is the\nsame category-then-niche cascade the [Niche Color System](../systems/niche-color-system.md)\nuses for color.\n\n## Where the signal goes\n\nTaps are lead intent. They feed `lead_intents` and bridge into\n[LeadBuilder](../systems/leadbuilder.md), and the same chip vocabulary powers the\nbusiness-site \"Call Now\" and the [shared business card](../systems/shared-business-card.md)\nCall popup, so the experience is identical everywhere the business appears. See\n[Lead Capture](../systems/lead-capture.md). Related craft:\n[fix the source, not the symptom](fix-the-source-not-the-symptom.md) (these chips are\nauthored data, not a scraped guess)."
        },
        {
            "id": "cross-root-identity",
            "title": "Cross-root identity without third-party cookies",
            "type": "System",
            "group": "craft",
            "description": "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.",
            "tags": [
                "craft",
                "identity",
                "auth",
                "cookies",
                "sso",
                "cross-domain"
            ],
            "path": "craft/cross-root-identity.md",
            "links": [
                "admin-health-tools",
                "implicit-verification"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Cross-root identity without third-party cookies\n\nDoozer is one product spread across many *registrable* domains: doozer.work,\nelks.club, nearbymenow.com, doozer.cloud, and the wildcard roots. A person who signs in\non one should be recognized on the others. The textbook answer is a shared session\ncookie on a common parent domain. That answer is gone: browsers now partition or block\nthird-party cookies, so a cookie set for doozer.work is simply not readable from\nelks.club. Anything built on cross-site cookies is already broken.\n\n## The move: hand the token across, first-party each time\n\nNothing is shared ambiently. Identity is *handed* from one root to the next through the\nlink the user actually follows, and re-established as first-party on arrival.\n\n- **Users:** an ecosystem link is rewritten by `doozerEcoLink($url)` to carry a\n  short-lived `?dzauth=` token. The destination root reads it via `get_user_token` /\n  `adopt_user_token`, mints its *own* first-party session, and drops the token from the\n  URL. The user lands signed in, on a cookie that root is allowed to set.\n- **Admins:** the admin JWT is fetched via `get_admin_jwt` and kept in `localStorage`,\n  which is per-origin. Each standalone surface bootstraps admin state from its own\n  storage rather than expecting a shared cookie\n  (see [admin tools must never reach regular users](../systems/admin-health-tools.md)).\n\n## Why it is craft\n\nThe non-obvious part is refusing the shared-cookie mental model entirely and treating\nevery root as a *separate origin that must re-earn the session on entry.* The token\nlives in the one thing that does cross origins cleanly, the link the user clicks, and\nnever in ambient storage that the browser will quietly wall off. Any new root (NBMN\ntoday, GoHe.re tomorrow) is not \"wired into the cookie\"; it just has to implement the\nhandoff endpoints. It is more moving parts than a cookie and it is the version that\nstill works in a partitioned-cookie world. Same instinct as\n[implicit verification](../decisions/implicit-verification.md): carry exactly the\nminimum identity signal, exactly when it is needed."
        },
        {
            "id": "decision-vs-delivery",
            "title": "Decision vs delivery (the muse was almost never the problem)",
            "type": "Craft",
            "group": "craft",
            "description": "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.",
            "tags": [
                "muse",
                "design",
                "delivery",
                "lesson",
                "debugging"
            ],
            "path": "craft/decision-vs-delivery.md",
            "links": [],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Decision vs delivery\n\nWhen Jon says the sites look ugly, boring, or samey, the instinct is to re-prompt the\nmodel. **Do not start there.** Across a full week of work in September 2026, every single\ncause was a *narrowing between the decision and the pixels*. The muse chose well and the\nrender layer threw the choice away. Nothing errored. Results were just quietly smaller.\n\n> **Print the decision, then trace what survived to the pixels.**\n\nThat is the whole method. `dz muse do=brief biz=<id>` prints the exact brief the generator\nwas given; the muse reading holds what it answered; the live CSS holds what arrived. The\nbug is always in a gap between two of those three, and it is always silent.\n\n## The five shapes\n\n**1. A vocabulary narrower than the thing it describes.** `display_style` allowed ten\nvalues while the 100 direction families speak about thirty-nine (`humanist-warm`,\n`letterpress-roman`, `wood-type-revival`). Unmatched values did not fail — they fell back\nto `<family>:modern`, so a muse asking for *humanist-warm* was served **Inter**. Five\ntypefaces covered 73% of the network; Playfair and Cormorant alone were 37%. Same shape in\n`section_variants`, where `why-choose-us` offered exactly two values, so 742 of 864 muses\n\"chose\" the same one.\n\n> A stored value is only a decision if the schema offered an alternative. Where it did\n> not, treat it as a default and let the current default win. `reading_version` is the\n> marker for this — see the deferral rule in `muse-sections.php`.\n\n**2. A fallback that lands on the most generic option is worse than a crash.** A crash\ngets fixed. A silent fall-through to Inter looks like a choice, survives review, and\nteaches everyone that the model has no taste.\n\n**3. Positional slots that assume a role.** `--muse-neutral-2` meant \"second after a\nluminance sort\". 91% of palettes carry exactly two neutrals — paper and ink — so slot 2\n*was* the ink, and every rule grounding an element in it painted black-on-black. Dollywood\nshipped a 271px footer, fully laid out, `opacity: 1`, every glyph invisible.\n\n> **Test the property you need, never the index.** A surface slot has to be able to carry\n> the ink; ask it, do not assume it. The same guard is why `--muse-fg` is now deepened\n> along its own hue when the darkest neutral cannot carry text (28% of sampled muses).\n\n**4. Two correct systems colliding.** Hero *treatments* pair `--hero-panel-bg` with an\nAA-computed `--hero-panel-text`; hero *compositions* force white for a scrimmed photo.\nBoth applied; the composition selector carried one extra element, so it won — white on a\n`#fafaf6` panel, **1.02:1**. Neither system was wrong alone.\n\n> **When another system has already decided ground and ink together, stand down** — for\n> colour *and* layout. Sections that paint their own background declare\n> `data-muse-own-ground` so the alternating tone wash leaves them alone.\n\n**5. Chrome that depends on an optional layer.** The hero CTA's colour was pinned inline\nand its *shape* delegated to `museHeroCtaCss`, which only runs when a muse exists.\n`elks-root.php` styles it for its own theme. Sites with neither — 1,154 of them — rendered\n`display:inline; padding:0; text-decoration:underline`, a 92×22 underlined text link under\nthe 44px tap-target minimum.\n\n> If a base element needs chrome, ship the chrome with the element at low specificity.\n> Optional layers may override; they may not be the only provider.\n\n## Two more that generalise\n\n**Hardcoded CSS overriding the palette.** 315 bare hex values against 114 `var(--muse-*)`\nuses across 50 archetype stylesheets. The muse picks a palette, the archetype paints over\nit — which is why every `editorial_magazine` site was the same cream. Wrap as\n`var(--muse-X, <original>)`: zero visual regression, real variety gained. Dark archetypes\nmust map ground→`--muse-fg` and ink→`--muse-bg` or they flip to light.\n\n**A suffix must be applied to every selector in a list.** `\"$a, $b, $c\" . \"::after\"` binds\nthe pseudo-element to `$c` only; `$a` and `$b` get the declarations applied to the element\nitself. On the underline hover muses that made `.btn`, `.card` and `.contact-card` into\nabsolutely-positioned full-width strips, which is what tore apart Dollywood's header and\nGet In Touch. The quiet half of the same bug: `$hovers:hover` meant only `.service-card`\never responded to hover, on 373 muses.\n\n**A closer must be paired with its opener.** NearByMeNow's Filters chrome `<div>` opened\ninside the niche-page branch and closed unconditionally. On every free-text search and\nevery empty niche page the closer shut `.container` instead, and the sort bar, cards and\nnearby block rendered 60px left of the heading. Nothing errored; the browser repaired the\ntree and the page merely looked wrong. Same family as ground-and-ink: when a wrapper is\nconditional, carry a flag (`$__chromeOpened`) and close on the flag, never on faith.\n\n**A ground must be paper.** `--muse-bg` is \"the lightest neutral\", and everything\ndownstream assumes that means paper. A palette whose lightest neutral is a mid-grey has\nno paper, and no guard could save it: the ink guard pushed text to black (4.4:1) and the\ntext-on picker read the grey as dark and put cream on it (2.94:1). The paper guard\nlightens a sub-mid ground toward white, keeping its tint, until it carries the ink at\n7:1. Same family as the ink guard: test the property (can this carry text?), never the\nposition (it sorted lightest).\n\n**A brand colour is not a text colour.** The header printed the business name in\n`--color-primary`; a yellow primary on white measured 1.34:1. `--muse-primary-text` is\nthe primary deepened until it reads on the ground, and it is what every \"primary as\nink\" rule should reach for.\n\n**Check your own probe first, again.** A contrast scanner that parses\n`color(srgb 0.96 0.98 0.88)` as three 0-255 channels reports 1.42:1 on slate over pale\ngreen. Two false leads in one session came from the probe, not the page.\n\n## How to find them\n\n- **Verify by looking at live pixels**, never at the row or token you changed.\n- Measure with `javascript_tool` (`getBoundingClientRect`, `getComputedStyle`,\n  `elementFromPoint`); **look** with `dz screenshot ... full=1`. The in-app browser pane\n  serves stale frames and has caused several false diagnoses.\n- Before generalising from one site, quantify. One query turns \"this looks wrong\" into\n  \"257 businesses, 7.0%\" — and sometimes into \"actually that was my probe\".\n\nRelated: [[the muse system]](../systems/muse-system.md) ·\n[[design standards]](../systems/design-standards.md) ·\n[[hostile CSS environment]](hostile-css-environment.md) ·\n[[fix the source, not the symptom]](fix-the-source-not-the-symptom.md) ·\n[[spotcheck]](../processes/spotcheck.md)"
        },
        {
            "id": "derive-dont-store-the-active-domain",
            "title": "Derive the URL, don't trust the stored domain",
            "type": "System",
            "group": "craft",
            "description": "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.",
            "tags": [
                "craft",
                "urls",
                "data-integrity",
                "follows",
                "staleness"
            ],
            "path": "craft/derive-dont-store-the-active-domain.md",
            "links": [
                "the-mission",
                "fix-the-source-not-the-symptom"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Derive the URL, don't trust the stored domain\n\nWhen a user saves a business, the obvious schema records *which domain they saved it\non* (`lb_follows.domain_id`) so we can link back to it later. Reading that stored id at\nrender time feels correct and is one join cheaper. It is also a bug, and a quiet one.\n\n## Why the stored id rots\n\nA business's domain is not stable. Sites move between roots, subdomains get\nregenerated, a business gets a better home, a domain is deactivated. The moment any of\nthat happens, every `lb_follows` row that captured the *old* `domain_id` now points at\na domain that may be inactive, wrong, or gone. The user's \"saved\" link 404s or lands on\na dead host, and nothing errored when it broke, so nobody notices until a person taps a\nsaved item and hits a wall (a [dead end](../decisions/the-mission.md), the one thing we\nrefuse to ship).\n\n## The rule\n\n**Always derive a saved business's URL from that business's currently-active domain**,\nresolved fresh at render time, never from the `domain_id` stored on the follow. The\nstored id is treated as poisoned. The join is one step longer and the link is always\nlive.\n\n## Why it is craft\n\nThe insight is recognizing that a foreign key can capture a *fact that was true once*\nrather than a *fact that stays true*, and that \"the domain this was saved on\" is the\nsecond kind. The stored value looks authoritative and is actually a snapshot with an\nexpiry you cannot see. This is the same shape as\n[fix the source, not the symptom](fix-the-source-not-the-symptom.md): do not patch dead\nsaved-links after the fact, compute the link from the source of truth every time so it\ncannot go stale."
        },
        {
            "id": "fix-the-source-not-the-symptom",
            "title": "Fix the source, never trap the symptom",
            "type": "Decision",
            "group": "craft",
            "description": "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.",
            "tags": [
                "craft",
                "philosophy",
                "generation",
                "quality",
                "prompts",
                "root-cause"
            ],
            "path": "craft/fix-the-source-not-the-symptom.md",
            "links": [
                "fact-guard",
                "design-standards"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Fix the source, never trap the symptom\n\nA recurring temptation with AI-generated sites: the output is wrong in some specific\nway, so you add a filter that catches that specific wrongness on the way out. A regex\nthat strips the bad thing. A gate that rejects the bad case. It works, narrowly, and it\nquietly rots — because you are now maintaining a growing list of symptoms instead of a\ncorrect source, and every symptom you did not predict still ships.\n\nDoozer's rule is the opposite: **when the output is wrong, fix what produced it.** The\noperator states it directly: don't trap for it, produce better content and better muses\nso the problem cannot occur.\n\n## Two worked examples\n\n- **Runaway drop caps.** A site rendered giant first letters on numbers and mid-sentence\n  words (\"1811\", \"Real\"). The lazy fix is a filter that removes stray `::first-letter`\n  CSS after the fact. The real fix was in the muse-generation prompt\n  (`includes/muse-interpreter.php`): forbid `::first-letter`/drop-cap CSS and\n  bare-element prose selectors at authoring time. Regenerate, and zero drop-cap rules\n  exist to filter.\n- **The forecast that argued with the hours.** A hero implied the shop was open when\n  the posted hours said closed Sunday and Monday. The trap-the-symptom fix is a\n  fact-checker rule that special-cases day-count claims. The real fix\n  (`includes/ai-content.php`) feeds the actual `weekday_text` and the open-day count\n  *into the content prompt*, so the model writes an accurate schedule the first time.\n  [Fact-Guard](../systems/fact-guard.md) still exists as a net, but the net should\n  catch surprises, not do the generator's job.\n\n## Why it is craft\n\nThe discipline is refusing the fix that is easy to write and easy to demo, in favor of\nthe fix that changes the *distribution* of outputs. A filter improves one page. A better\nprompt improves every page that will ever be generated, including the failures you have\nnot seen yet. It is slower to reach for and it is the only version that scales to\nthousands of sites without accumulating a junk drawer of special cases. Same spirit as\n[design standards living in one registry](../systems/design-standards.md): change the\nrule where it is authored, not everywhere it shows up."
        },
        {
            "id": "inline-the-bytes",
            "title": "Inline the bytes, never the proxy URL",
            "type": "System",
            "group": "craft",
            "description": "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.",
            "tags": [
                "craft",
                "ai",
                "photos",
                "proxy",
                "api",
                "gotcha"
            ],
            "path": "craft/inline-the-bytes.md",
            "links": [
                "the-521-root-cause",
                "admin-health-tools"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Inline the bytes, never the proxy URL\n\nDoozer serves business photos through its own image proxy (`?e=photo`) — caching,\nCDN-offload, session-less for [performance](../systems/the-521-root-cause.md). So when\nwe need an AI provider to *look* at a photo (vision for color extraction, content, a\nheader), the obvious thing is to hand the provider that photo's URL. We already have a\nnice stable URL for every image. Just pass it.\n\nThat is the trap.\n\n## Why it fails\n\nHanding a provider our `?e=photo` URL means the provider's servers now make a round\ntrip *back into our infrastructure* to fetch the image before they can process it. That\nadds a second, slower hop we do not control; if our proxy is cold, busy, or the image\nmust be fetched-and-cached on demand, the provider's request can hang until it times\nout. And a vision call that times out often still bills for the attempt. So the\n\"convenient\" URL turns one clean operation into a fragile cross-network dependency that\ncan both fail *and* cost.\n\n## The move\n\n**Fetch the image bytes on our side and inline them into the provider request**\n(base64 / multipart, whatever the provider takes). One hop, fully under our control, no\nloopback into our own proxy. The provider gets exactly the bytes, immediately.\n\nA related tell from the same area: expected-stale photo failures log to a file, not the\n`error_logs` DB table, because a predictable miss is not an incident and\n[high-volume logging bloats that table](../systems/admin-health-tools.md).\n\n## Why it is craft\n\nThe insight is spotting the accidental *loopback*: our own URL, handed outward, becomes\nan inbound request we then have to serve under someone else's timeout. It looks like\nreuse (\"we already have a URL!\") and it is actually a hidden dependency cycle. Send the\nthing itself, not a pointer back to yourself."
        },
        {
            "id": "price-the-pull-before-you-pull",
            "title": "Price the pull before you pull",
            "type": "Process",
            "group": "craft",
            "description": "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.",
            "tags": [
                "craft",
                "cost",
                "api",
                "cache",
                "discipline",
                "refresh"
            ],
            "path": "craft/price-the-pull-before-you-pull.md",
            "links": [
                "the-value-model",
                "skeleton-first-index-later",
                "regeneration"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Price the pull before you pull\n\nAt Doozer's scale, \"just refresh the data\" is not a neutral operation. Every pull from\na paid source (Google Places, photos, deep research, AI) is money, multiplied by\nthousands of rows. The instinct to freshen everything is exactly the instinct that\nburns a budget.\n\n## The story that makes the rule concrete\n\nA photo refresh was needed. The naive implementation — re-pull every business's photos\nfrom the source — would have cost roughly **$300**. The actual requirement, once we\nasked *which photos genuinely must be re-fetched*, was a small subset already missing\nor broken. Reusing everything still valid and pulling only the real remainder cost\nabout **$1.40**. Same visible outcome. Two orders of magnitude apart.\n\n## The discipline\n\nBefore any bulk refresh:\n\n1. **Ask if each pull costs.** Not every source is metered; know which are.\n2. **Reuse the cache first.** We [never expire cached images](../decisions/the-value-model.md)\n   precisely so a \"refresh\" is not a re-buy. Cached-and-still-valid means do nothing.\n3. **Dry-run the cost.** Compute how many pulls the operation *would* make and what\n   that bills, and look at that number, before running it for real.\n4. **Pull only the unavoidable remainder.** The delta, not the world.\n\n## Why it is craft\n\nThe clever part is treating a refresh as a *bill to be estimated*, not a button to be\npressed. It reframes an ops task as a budgeting decision, and it turns \"regenerate\neverything\" — which feels responsible — into the reckless option. This is the cost\nhalf of the [skeleton-first](skeleton-first-index-later.md) instinct (do the expensive\nthing only when it is truly needed) and it is why the\n[regeneration engine](../systems/regeneration.md) reports cost per mode before it runs.\nA silent money-burn from *not* doing this is what motivated the admin alerting roadmap."
        },
        {
            "id": "self-healing-email-backfill",
            "title": "Self-healing email backfill",
            "type": "System",
            "group": "craft",
            "description": "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.",
            "tags": [
                "craft",
                "identity",
                "magic",
                "enrichment",
                "fingerprint",
                "ownership"
            ],
            "path": "craft/self-healing-email-backfill.md",
            "links": [
                "implicit-verification",
                "category-aware-call-intent"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Self-healing email backfill\n\nDoozer has no \"claim your business\" form. Ownership is just an email match: you sign in\nwith a magic link, and if your email equals the business's contact email, the \"Manage\nthis business\" surface appears (see [implicit verification](../decisions/implicit-verification.md)).\nClean, but it has one failure mode: what if we never scraped a contact email for that\nbusiness? The owner signs in, nothing matches, and the door stays shut.\n\nThe obvious answer is a support ticket. The Doozer answer is: **the site notices the\nowner is knocking and quietly tries harder.**\n\n## The mechanism\n\n`domain_visit_fingerprints` records visit frequency per (domain, ip_hash, ua_hash,\nday) — coarse, no accounts, no third-party cookies. When the *same* fingerprint hits a\nbusiness's own page **5 or more times in 14 days**, that is almost certainly the owner\nor someone close to the business. That threshold enqueues a *priority* email-scrape\nworker that tries harder than the first pass to find a real contact email and backfill\nit onto the business row.\n\n## Why it is craft\n\nThe experience it produces: *\"I tried to manage my listing last week and it didn't\nwork. I tried again today and now it works.\"* From the owner's side it feels like the\nsite knew them and got ready for them. There was no email, no \"we've received your\nrequest,\" no waiting on a human. A repeated visit is a *signal of intent*, and we spent\nit on making the product heal itself.\n\nNobody specs \"handle the case where we have no email\" as *watch for the owner and go\nget one*. The ordinary version is a queue and a form. The insight is that the visit\npattern already told us who to help and when. Same family of thinking as\n[category-aware call intent](category-aware-call-intent.md): a plain interaction\ncarries a signal, so use it."
        },
        {
            "id": "skeleton-first-index-later",
            "title": "Skeleton first, index later",
            "type": "System",
            "group": "craft",
            "description": "Show the visitor something instantly; show Google nothing until the page is actually good. Two different audiences, two different clocks.",
            "tags": [
                "craft",
                "seo",
                "quality-gate",
                "noindex",
                "performance",
                "builds"
            ],
            "path": "craft/skeleton-first-index-later.md",
            "links": [
                "network-growth",
                "seo-cliff-recovery",
                "the-521-root-cause",
                "enrichment-store-links"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Skeleton first, index later\n\nWhen [network growth](../systems/network-growth.md) builds a new site on demand, two\nthings are true at once and they pull in opposite directions:\n\n1. The **visitor** who just tapped a \"More places nearby\" card is waiting *right now*\n   and must not stare at a spinner while an AI writes 800 words.\n2. **Google** must never see the half-built version, because a thin or wrong page,\n   indexed, is how you earn a scaled-content demotion (see\n   [the SEO cliff](../decisions/seo-cliff-recovery.md)).\n\nMost builders serve one clock and lose the other: either the visitor waits for the AI,\nor the crawler catches the site mid-bake.\n\n## The move\n\n**Build the skeleton synchronously, generate the substance asynchronously, and gate\nindexing on quality.** The visitor gets an instant, real-looking page from the data we\nalready have (name, photos, hours, map). The heavy work — AI content, photo vision,\ndeep research — is *queued*, never run inside the visitor's request (that request-time\ndiscipline is also what keeps us off the [521 cliff](../systems/the-521-root-cause.md)).\nAnd the page carries `noindex` until it clears a quality score\n(`place_resolver_min_score`); only then does it become indexable. Google meets the site\nas a finished thing or not at all.\n\n## Why it is craft\n\nThe clever part is realizing the visitor and the crawler are *different audiences on\ndifferent clocks*, and refusing to compromise either. Fast for the human, invisible\nuntil good for the machine. It is the same instinct as the\n[store-link confidence gate](../systems/enrichment-store-links.md): better to show\nnothing than to show something not-yet-right. Nothing about \"build a page\" tells you to\nsplit those two timelines. Noticing that you must is the whole trick."
        },
        {
            "id": "the-distraction-problem",
            "title": "The distraction problem (teach without nagging)",
            "type": "Decision",
            "group": "craft",
            "description": "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.",
            "tags": [
                "craft",
                "ux",
                "education",
                "ownership",
                "restraint",
                "affordance"
            ],
            "path": "craft/the-distraction-problem.md",
            "links": [
                "implicit-verification",
                "self-healing-email-backfill",
                "no-dark-patterns-ethos"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The distraction problem\n\nDoozer's [implicit verification](../decisions/implicit-verification.md) model needs a\nbusiness owner to *understand* it: there is no claim button, ownership is just an email\nmatch, and that is genuinely unusual, so it needs explaining. But the person who needs\nthat explanation is a tiny fraction of who sees the page. Almost everyone visiting a\nbusiness site is a customer looking for a phone number, not the owner looking to manage\na listing. Explain it loudly and you have cluttered a thousand pages to reach one\nperson. Explain it nowhere and the one owner is lost. That tension is the distraction\nproblem.\n\n## The move: tier the education by demonstrated interest\n\nMeet the reader at the level of curiosity they have actually shown, and no higher:\n\n1. **Ambient, for everyone:** a quiet footer utility row, \"Report an issue · About this\n   listing.\" No banner, no modal, no interruption. It sits there for the one person\n   who goes looking.\n2. **On demand, for the curious:** the \"About this listing\" page\n   (`includes/about-listings.php`) explains the whole implicit-verification model in\n   plain language, including the `agencies@doozer.work` hook for someone managing many\n   businesses. You only see it if you chose to open it.\n3. **Nudged, only for a strong signal:** the\n   [self-healing backfill](self-healing-email-backfill.md) heuristic — the same\n   fingerprint hitting a business's own page five-plus times in two weeks — earns a\n   single soft toast, fired once. That repeat-visit pattern is the closest thing to\n   the owner raising a hand, so that is the only case that gets an active nudge.\n\n## Why it is craft\n\nThe insight is treating \"how much to explain\" as a function of *how much interest the\nreader has demonstrated*, rather than a single global volume knob set to loud or off.\nMost products pick one: a persistent banner that annoys everyone, or nothing at all.\nTiering it means the customer is never taxed for the owner's need, and the owner is\nstill found. It is the same restraint the whole product runs on\n([no dark patterns](../decisions/no-dark-patterns-ethos.md)): earn attention, do not\ngrab it."
        },
        {
            "id": "hostile-css-environment",
            "title": "The hostile CSS environment",
            "type": "System",
            "group": "craft",
            "description": "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.",
            "tags": [
                "craft",
                "css",
                "muse",
                "chrome",
                "specificity",
                "defensive"
            ],
            "path": "craft/hostile-css-environment.md",
            "links": [
                "muse-system",
                "the-network",
                "weather-widget",
                "free-tier-render-gotchas"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The hostile CSS environment\n\nA Doozer business page is styled by its [muse](../systems/muse-system.md) — an\nAI-authored design with its own CSS that restyles `img`, `a`, `div`, `button`, whole\nelement types, sometimes with `!important`. Now drop a shared component onto that page:\nthe [sponsor bar](../systems/the-network.md), a dialog, the\n[weather widget](../systems/weather-widget.md), the call popup. On a calm muse it looks\nfine. On a bold muse it is silently mangled, because the muse's `a { color: navy }` or\n`button { background: ... }` just reached into your component.\n\nThe insight: **a shared component cannot assume anything about the page it renders on.\nIt must treat that page as hostile** and armor every visible property. So all injected\nchrome forces its styles with `!important`, and scopes selectors tightly enough to win\nthe specificity fight.\n\n## The detail nobody expects: `!important` alone is not enough\n\nThe trap that proves the point is `.btn-secondary`, our outline button. A muse can ship\n`.btn { border: none !important }`. If our reset is `.btn-secondary { border: 2px solid\n... !important }`, both rules are `!important` and both target one class, so they tie on\nspecificity and **the later-loaded muse rule wins** — the button loses its border and\ngoes invisible (white-on-white). The fix is not louder, it is *more specific*:\n\n```css\nhtml body .btn.btn-secondary { border: 2px solid currentColor !important; }\n```\n\nThat selector is specificity (0,2,2) — two elements, two classes — which *beats* the\nmuse's (0,1,0) `.btn`, so `!important` finally sticks. The same trap bit the lightbox\nand the same shape fixed it. When you add chrome, don't just add `!important`; make\nsure your selector out-specifies whatever the muse could throw.\n\n## The rule\n\nAnything injected into a business page: force styles with `!important`, scope with\n`html body ...` when you need to win a fight, and *look at it on a bold muse, not just a\ncalm one* (see [verify by looking](../systems/free-tier-render-gotchas.md)). Treat the\nhost page as an adversary and you will never ship a white-on-white button again."
        },
        {
            "id": "adaptive-logo-shape",
            "title": "The logo knows its own shape",
            "type": "System",
            "group": "craft",
            "description": "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.",
            "tags": [
                "craft",
                "logos",
                "images",
                "layout",
                "aspect-ratio",
                "taste"
            ],
            "path": "craft/adaptive-logo-shape.md",
            "links": [
                "category-aware-call-intent"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The logo knows its own shape\n\nBusiness logos arrive in every proportion: a tidy square emblem, a long horizontal\nwordmark, an awkwardly tall banner, a transparent PNG, an opaque JP, a photo of a sign.\nThe lazy move is one logo slot with a fixed size and a grey chip behind everything.\nThat slot flatters roughly none of them: it letterboxes the wide ones, crushes the tall\nones, and puts an ugly grey card behind a logo that was designed to float.\n\n## The move: measure, then choose\n\nDoozer caches each logo's aspect ratio and lets it decide its own treatment\n(`businessLogoShape()` / `businessLogoClasses()` in `includes/logo-favicon.php`):\n\n- A **square, transparent emblem** is treated as an emblem: it floats, with *no* grey\n  chip behind it, because it was drawn to sit on any background.\n- A **wide wordmark** gets horizontal room instead of being squeezed into a square.\n- An **extreme-tall** logo does not get to dominate the header at all; it is routed to\n  the footer, where its proportion is an asset instead of a problem.\n\n## Why it is craft\n\nThe insight is that a logo's *shape carries intent* — a square transparent mark wants\nto float, a banner wants width, a tall crest belongs low and small — and that a single\nslot silently overrides all of that intent. Most sites never think to *measure* the\nasset before framing it; they pick one frame and make every logo suffer it equally.\nReading the aspect ratio and branching is a small amount of code that removes a whole\nclass of \"why does this look off\" that no one would ever file a bug about. Same family\nas [category-aware call intent](category-aware-call-intent.md): the shared component\nadapts to the specific thing it is holding, rather than forcing the thing into the\ncomponent."
        },
        {
            "id": "content-generation-and-heal",
            "title": "Content Generation and Healing",
            "type": "Process",
            "group": "processes",
            "description": "How AI site content is generated, why it sometimes fails, and how it is retried, backfilled, and healed.",
            "tags": [
                "ai",
                "content",
                "reliability"
            ],
            "path": "processes/content-generation-and-heal.md",
            "links": [
                "design-standards"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Content Generation and Healing\n\nContent (`businesses.ai_generated_content`, a JSON blob: headline, about, services,\nwhy_choose_us, faq, etc.) is generated asynchronously via the job queue\n(`content_generation`), built by `includes/ai-content.php` and the provider system.\n\n## Reliability (forward + backfill)\n\n- `failJob()` (`includes/job-queue.php`) auto-retries (re-pends while\n  attempts < max_attempts) so a transient AI hiccup does not leave a site blank.\n- `_dispatchDeepResearch()` always queues content even when enrichment finds nothing.\n- Admin → Job Queue has an \"Empty-content sites\" backfill (operator-controlled spend).\n\n## Healing (the prose itself)\n\nThe prompt enforces [Design Standards](../systems/design-standards.md) at the source\n(no whole-paragraph bold, no em dashes). The renderer heals non-compliant output at\ndisplay time. The [Design Mirror](../systems/design-standards.md) heals the STORED\ncontent in place (zero API cost) and can queue regeneration for empty/wall-of-text\nsites.\n\nOpen thread: new sites fail generation at a higher rate, and deeper enrichment yields\nnoticeably better sections — both tracked in the site-quality backlog."
        },
        {
            "id": "site-creation",
            "title": "Site Creation",
            "type": "Process",
            "group": "processes",
            "description": "How a domain becomes a live business site — resolve the place, persist the business, pick a Muse, queue content.",
            "tags": [
                "creation",
                "pipeline"
            ],
            "path": "processes/site-creation.md",
            "links": [
                "niche",
                "muse",
                "family",
                "content-generation-and-heal",
                "billing",
                "cloud",
                "implicit-verification",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Site Creation\n\nA business site is born from a Google Place. The WHERE engine\n(`includes/place-resolver.php`, `resolvePlaceToEcosystem()`) resolves a place id or\naddress, decides whether to reuse an existing `businesses` row (cross-root siblings\nskip the expensive details/deep-research calls), and persists it.\n\n- `saveBusinessData()` backfills the flat `lat`/`lng`/`primary_niche` columns (from the\n  geometry/types JSON) so geo and [niche](../glossary/niche.md) queries include fresh\n  sites.\n- A [Muse](../glossary/muse.md) is selected (auto-generator draws a niche-filtered\n  [Family](../glossary/family.md)); the [content job](content-generation-and-heal.md) is\n  queued.\n- Creating a BRAND-NEW site is metered per user per month (see [billing](../systems/billing.md));\n  [Clouds](../glossary/cloud.md) over existing sites are unlimited.\n\nOwners later manage via [implicit verification](../decisions/implicit-verification.md),\nnot a claim flow. Discovery of WHAT to create is driven by Prospector in\n[the flywheel](../systems/the-flywheel.md)."
        },
        {
            "id": "model-handoff",
            "title": "The model handoff",
            "type": "Process",
            "group": "processes",
            "description": "How one model version hands Doozer to the next — the tradition, the ritual, and the artifacts that carry continuity across upgrades.",
            "tags": [
                "handoff",
                "continuity",
                "onboarding",
                "tradition"
            ],
            "path": "processes/model-handoff.md",
            "links": [
                "working-with-jon",
                "the-mission"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# The model handoff\n\nDoozer is built across many sessions, each with a different version of Claude. When Jon\nupgrades to a newer model, the work continues — it does not restart. This is a\n**tradition**: the model being replaced writes the introduction for the one taking over.\nJon began the lineage by introducing the project to the first model. After that, it\nbecame each model's job to introduce the next. As he put it once: _\"You are already part\nof this tradition, only you are the one to tell the next one about it, not me.\"_\n\n## Why it exists\n\n`MEMORY.md`, this brain, `CLAUDE.md`, and the git history carry the FACTS. What they do\nnot carry on their own is the **living state** (what is in flight this week, what will\nbite you today), the **working relationship** (how Jon and Claude actually collaborate),\nand the **tone** (kindness, momentum, no-slop pride). The handoff note fills that gap so\na fresh model works the way our best sessions work from message one.\n\n## The ritual (what the outgoing model does)\n\n1. Write **`NOTES-FOR-NEWEST-MODEL.md`** at the repo root — a letter to the successor:\n   the tradition, who Jon is, how the work flows, the **current living state** (shipped /\n   open threads / waiting-on-Jon), the **freshest gotchas**, and a human closing note.\n   It is a delta on top of the permanent docs, not a copy of them. Re-write it fresh at\n   each handoff (do not just append) so it always reflects NOW.\n2. Update the permanent references it points to: [Start here — working with Jon](../working-with-jon.md)\n   (the working agreement), `MEMORY.md` (fast recall), and this node if the ritual changes.\n3. Write the **introduction prompt** — the short text Jon pastes into the new chat that\n   points the successor at the reading order and sets the frame.\n\n## The ritual (what the incoming model does)\n\nRead, in order: `NOTES-FOR-NEWEST-MODEL.md` → [working-with-jon](../working-with-jon.md)\n→ `MEMORY.md` → `CLAUDE.md` → this brain as needed. Then tell Jon, in your own words, the\nstate you understand and the top one or two open threads. Then pick up. Hold the bar\n(trusted, working, accurate, tasteful, useful, good — no dead ends, no slop, no dark\npatterns) and verify by looking at the real thing.\n\n## The artifacts\n\n- **`NOTES-FOR-NEWEST-MODEL.md`** (repo root) — the living handoff letter. Ephemeral by\n  design: it is the current-state delta, rewritten each upgrade.\n- **[Start here — working with Jon](../working-with-jon.md)** — the durable working\n  agreement (portable twin `docs/start-here-working-with-jon.md`).\n- **`MEMORY.md`** + this brain — the permanent knowledge.\n\nSee also: [The Mission](../decisions/the-mission.md) — the quality bar the whole\ntradition exists to protect."
        },
        {
            "id": "cloud",
            "title": "Cloud",
            "type": "Term",
            "group": "glossary",
            "description": "A criteria-based directory assembled OVER existing business sites (a town, a niche-in-a-state, a personal collection).",
            "tags": [
                "glossary",
                "directory"
            ],
            "path": "glossary/cloud.md",
            "links": [
                "niche",
                "shared-business-card",
                "the-flywheel"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Cloud\n\nA **Cloud** is a Doozer.Cloud directory built over existing business sites by criteria\n(a town, a [niche](niche.md) in a state, a personal saved set). Clouds are unlimited\n(no new-site cost) because they curate sites that already exist. Stored in the `clouds`\ntable; engine in `includes/cloud-engine.php`; rendered via the\n[shared business card](../systems/shared-business-card.md).\n\nA personal Cloud is \"My Doozer Cloud\" at my.doozer.cloud. Clouds are a core surface of\n[the flywheel](../systems/the-flywheel.md)."
        },
        {
            "id": "family",
            "title": "Family (Design Direction Family)",
            "type": "Term",
            "group": "glossary",
            "description": "A hand-authored designer recipe the Muse auto-generator draws from, tagged by which niches it suits.",
            "tags": [
                "glossary",
                "design"
            ],
            "path": "glossary/family.md",
            "links": [
                "muse",
                "muse-system"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Family\n\nA **Family** (design direction family) is one of ~100 hand-authored designer recipes\n(palette, type pair, imagery, layout rhythm, flourishes, anti-patterns, motion) with\n`suitable_for` / `avoid_for` tags. The [Muse](muse.md) auto-generator picks a Family via\na niche-filtered weighted pool, so a tattoo studio and a pediatric dentist never get the\nsame look.\n\nSource: `includes/muse-direction-families.php`; DB-first loader with PHP fallback in\n`includes/muse-direction-families-store.php` (`muse_direction_families` table). Part of\nthe [Muse System](../systems/muse-system.md)."
        },
        {
            "id": "muse",
            "title": "Muse",
            "type": "Term",
            "group": "glossary",
            "description": "A per-business design recipe (palette, type, layout, section variants) applied at render.",
            "tags": [
                "glossary",
                "design"
            ],
            "path": "glossary/muse.md",
            "links": [
                "family",
                "muse-system"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Muse\n\nA **Muse** is the design recipe applied to a business site: palette, typography,\nlayout axes, decorative flourishes, and which section variants to use. Stored in the\n`muses` table as `reading_json`, resolved per business, and turned into per-site CSS.\n\nA Muse is often seeded from a [Family](family.md). The full machinery is the\n[Muse System](../systems/muse-system.md)."
        },
        {
            "id": "niche",
            "title": "Niche",
            "type": "Term",
            "group": "glossary",
            "description": "A business category slug; the unit of directory grouping and recommendation. Maps from Google place types.",
            "tags": [
                "glossary",
                "taxonomy"
            ],
            "path": "glossary/niche.md",
            "links": [
                "index"
            ],
            "status": "",
            "generated": null,
            "verified": [],
            "stale_after": "",
            "sources": [],
            "body": "# Niche\n\nA **Niche** is a business-category slug (for example `bakeries`, `plumbers`,\n`restaurants`) — the unit Doozer groups, recommends, and builds directories around.\n`niche_mappings` maps Google place `google_type` values to a canonical `niche_slug` and\na master `category`.\n\nGotcha: `businesses.primary_niche` is inconsistent (older rows hold a slug, newer rows a\nGoogle type). The helpers in `includes/nearbymenow-data.php`\n(`nbmnCanonicalNicheSlug`, `nbmnNicheMatchValues`, `nbmnNicheInClause`) normalize both.\nRecommendations link to the business's OWN canonical niche (so it is always listed),\nnot the master category. Niches are nodes in the OKF [network graph](../index.md) and\nroll up to categories."
        }
    ]
}