HOME SERVICE WEBSITE ARCHITECTURE v4.2 + IMPLEMENTATION ANNEX — COMPLETE TEXT Fortitude Creative. https://fc-build-spec-a5aaf279.vercel.app/ Version 4.2. AN IMPLEMENTATION SPECIFICATION, NOT A WIREFRAME. US home-service SMBs: HVAC, electrical, plumbing, roofing and the trades. Document 1 is the architecture (Part 1, revision record, then the spec). Document 2 is the build spec, sections A-R. ================================================================ DOCUMENT 1 OF 2 ================================================================ # Home Service Website Architecture — Implementation Specification v4.2 Source: https://fc-build-spec-a5aaf279.vercel.app/ Publisher: Fortitude Creative. Last updated 2026-09-25. THE DOCUMENT SET - This document, the architecture: /wireframe-v4.md - Implementation Annex, the build spec: /annex.md (sections A-L) - AI Visibility Runbook: /ai-visibility.md (off-site protocols, citation panel) - Operational Runbook: /runbook.md (deployment and maintenance) - Both core documents in one fetch: /llms-full.txt - Everything condensed under 4,000 chars: /brief.txt THIS IS AN IMPLEMENTATION SPECIFICATION, NOT A WIREFRAME. It defines what content must exist, the data structures beneath it, and how quality is enforced at build time. It contains no page layouts and is not a client-facing document. v3 of this specification was judged an 85th-90th percentile architecture that could not be built as written, because it deferred five load-bearing decisions. v4 makes those five decisions and writes them as a spec an engineer can implement without asking a follow-up question. Everything in this document either resolves a deferred decision or states a rule that a template must enforce. Later rounds added conversion surfaces on top of this architecture without changing it: the installation estimator, capped equipment pages, specials and the review counter (annex section M), and the conversion and trust modules in annex section N. --- ## Part 1. Why this exists ### 1.1 The premise The most durable SEO strategy for a home service business — HVAC, electrical, plumbing, roofing, or any trade that sends a van — is to be demonstrably the most trustworthy choice for the homeowner, and to make that demonstrable in a form a machine can read. That is the whole argument. Everything downstream is mechanism. Search systems evaluating a local business are trying to establish three things: that it is real, that it is local, and that it is competent. This architecture proves all three by requiring documented jobs with photographs and locations, named technicians with verifiable credentials, a licence number checked against the issuing state's database, and reviews from customers who exist. A site meeting those requirements does not merely rank well. It deserves to, because it represents a business that actually does the work. **The structure makes the evidence machine-readable. The evidence itself is for humans.** If those two ever conflict, the human wins, and several decisions in this document were made on exactly that basis. ### 1.2 What this document is, and is not It is an implementation specification: content model, validation rules, schema, and the build gates that enforce them. An engineer can build from it. It is **not** a wireframe, a visual design, or a client-facing pitch. It contains no page layouts. Earlier versions were titled "wireframe" and that was simply wrong. **Scope: United States home-service SMBs.** Licence patterns, NAP conventions and regulatory assumptions throughout are US-specific. Internationalisation is out of scope and is not gestured at. ### 1.3 The dependency this asks of the business, stated before anything technical **This system produces empty templates when nobody documents the work.** Page publication is gated on evidence. A city page needs three documented jobs in that market. A service-and-city page needs one. A technician profile needs a job attached to that technician. When those do not exist, the templates render blocks saying so, in plain language, rather than filling the space with stock photography and adjectives. That is a deliberate design decision and it is the single largest risk to the whole approach. It is not a CMS problem or a developer problem. It is an operational workflow the business has to run: - A technician photographs before and after, on site, taking roughly two minutes - The office records the address and the job type - Someone reviews and publishes within 48 hours If that does not happen, the architecture will be correct and the site will be thin. No amount of engineering downstream fixes it, and this document would rather say so on page one than bury it. ### 1.4 Questions a reviewer should ask, answered **Is this just optimising for Google?** No, and the distinction matters. Structuring a licence number, a project photograph or a technician's credentials makes existing, real trust legible to a machine. Google's objective is to identify the most trustworthy local expert; the objective here is to prove that is this business. A crawler that cannot parse the evidence is a customer who never sees it. Nothing in this document asks anyone to manufacture a signal they have not earned. **Does a service-by-city page grid produce thin or duplicate content?** It would, without a gate. That is why there is one. Pages in the grid do not publish until a documented job exists for that service in that town, with photographs and a named technician. The theoretical maximum is cities multiplied by services; the actual page count is capped by how much work the business can document. A client producing ten jobs a month gets ten pages a month, and the specification says to cut the page count rather than the content floor. **Why insist on real project photographs instead of stock?** Because it is the only defence against commoditisation that a competitor cannot copy by buying the same template. Generic contractor sites use stock imagery and unverifiable claims. Attributing a specific job to a specific technician in a specific neighbourhood produces something checkable, which matters disproportionately in a category where a bad decision costs a homeowner real money and occasionally their safety. **My competitor ranks fine with a five-page site. Is this over-engineered?** For some businesses, yes, and that is a legitimate answer. A five-page site works until it needs to capture intent it has no page for, demonstrate expertise in a town it has never written about, or expand into a new service area. It also works indefinitely for a business with overwhelming brand authority. This architecture is built for the case where you cannot outspend or outrank on reputation alone, and it wins the specific queries that convert best by matching intent with proof. Worth saying plainly: a competitor with 140,000 reviews and fifteen years of links will outrank a structurally better site. Structure does not beat authority. It makes every review, link and job you do earn count for more. **Where are the colours, fonts and layouts?** Deliberately absent. This is brand-agnostic by design, so the same architecture serves an electrician in Texas and a roofer in Florida. Visual identity is applied on top during design. ### 1.5 What this document does not claim Several things here are honest bets rather than established mechanisms, and they are labelled as such where they appear: - **`llms.txt`** is cheap to ship and has no documented adoption by any major retrieval system. It is not sold as a ranking factor, and the build only warns when it is missing. - **`speakable` markup** has only ever been documented for news publishers. It is included as cheap positioning, targeted at the one node type where it is at least defined, and expected to do nothing. - **`dateModified` freshness** is a real signal for traditional crawlers and a useful forcing function against content rot. Its effect on AI retrieval is assumed, not demonstrated. - **Off-site authority dominates AI citation** for local commercial queries. Section L exists because the on-site work in sections A through K is necessary and insufficient, and pretending otherwise would be the most expensive kind of flattery. Three mechanisms proposed during review were rejected outright and the reasoning is recorded in place: a publish gate tied to Google review counts, which inverts cause and effect; automated demotion of requirements based on correlation, which is statistically undefined when every page implements every requirement; and failing production deploys over a missing `llms.txt`, which trades concrete harm for speculative benefit. A specification that cannot say what it is unsure about is not worth trusting on the parts it is sure about. --- ## Revision record This specification is in its fourth major version. Every correction below was made because a review found something wrong, and each is marked in place in the document rather than quietly absorbed. A document that cannot show its own corrections is asking to be taken on faith. ### Corrections applied | Corrected | From | To | Because | |---|---|---|---| | Project schema type | `HowTo` with `performer` | `Article` with `author` | `HowTo` has no `performer` property, so the reference silently dropped; Google retired HowTo rich results | | Location node id | `#local` | `#localbusiness` | The architecture doc and the annex disagreed; a developer reading only one would emit unresolvable references | | Person to Project link | `performerIn` | `author` on the Article | `performerIn` expects an Event, not a record of work | | Service slugs | `/heat-pumps/`, `/maintenance-plans/` | singular throughout | The annex locked a singular rule the sitemap then violated | | Business age field | `yearsInBusiness` | `yearEstablished` | A count is wrong the day after it is typed, and nothing detects the drift | | Zero-click event | `sg_click` | `search_bounceback` | It measured a return to search, not a zero-click visit. The name claimed something the metric could not see | | Speakable target | `LocalBusiness` and `Service` | the page's `WebPage` node | Speakable has never been documented for those two types | | Licence numbers | free text | verified against the issuing state's register | A licence number is a legal claim, not copy | ### Three mechanisms rejected Proposed during review, declined, with the reasoning kept in the document rather than the argument being erased: 1. **A publish gate tied to Google review count.** Blocking a new location's page until it has 20 reviews removes the page that generates reviews. Cause and effect inverted. 2. **Automated demotion of requirements by correlation.** Every published page implements every requirement by construction, so the independent variable has no variance and the correlation is undefined rather than merely weak. 3. **Failing production deploys over a missing `llms.txt`.** A concrete harm traded for a speculative benefit, on a file convention with no documented adoption. --- ## What changed from v3 | Deferred decision in v3 | v4 resolution | Where | |---|---|---| | **Service x location intersection** (was blocking the whole build) | Model A adopted. Dedicated intersection pages at `/[service]/[city]/`, self-canonical, with a content floor and one local project each. Location hubs become indexes, not competitors. | Section 1 | | Internal linking existed as documentation only | Moved into the CMS as required reference fields. Links are generated from relationships, not typed by hand. Orphan detection runs nightly. | Section 3 | | Schema listed as a stack of types | Rewritten as one entity graph joined by `@id`, so the site describes a company, its people, and its jobs rather than a pile of unconnected markup. | Section 4 | | Featured Local Projects assumed content capacity nobody had | A capture-to-publish workflow with a moderation gate, a named owner for every step, and an honest volume rule if the pipeline underdelivers. | Section 5 | | Problem pages depended on a click that AI Overviews now absorb | Conversion paths that survive zero-click, plus the diagnostic tool returned to urgent framing. | Section 6 | --- ## 1. The service x location decision `ac repair phoenix` is the query that pays. v3 published `/ac-repair/` and `/service-areas/phoenix/` and never said which one is supposed to rank for it. That ambiguity is how two pages end up competing for one query and neither wins. ### Adopted: Model A, dedicated intersection pages **URL convention, one convention only:** `/ac-repair/phoenix/`. Service first, city second. - **Why service-first:** authority accumulates on the service pillar, which is the harder thing to rank. Breadcrumbs read `Home > AC Repair > Phoenix`, which matches how the page is actually reached and how the entity graph nests. - **Every intersection page is self-canonical.** No canonical pointing back to the pillar. A page that canonicals away cannot rank, which defeats the reason it exists. - **The location hub becomes an index.** `/service-areas/phoenix/` links to every service intersection in Phoenix, carries the `LocalBusiness` entity, NAP, hours, and map, and holds the city's project feed. It does not try to rank for `ac repair phoenix`. - **Content floor per intersection:** 350-500 words of copy unique to that service in that city, plus one Featured Local Project from that city for that service. A page that cannot meet the floor does not get published. An unpublished page costs nothing; a thin one costs the whole cluster. **The rule that keeps this from becoming a doorway farm:** page count is capped by project supply, not by ambition. Cities x services is a theoretical maximum, never a build target. Ship the intersections you can actually fill, in descending order of revenue per job. ### Rejected: Model B, multi-intent location hubs One `/service-areas/phoenix/` page carrying every service query for that city. Viable only at 1,500+ words per city with unique H2s, FAQs, and projects per service section. **Why it lost:** it asks for more content per city than Model A does, while giving one URL to a dozen different money queries. The dilution is real and the internal linking logic never resolves cleanly. If the content team can write the mass Model B needs, they can write Model A's pages instead and get separate rankings for the effort. ### Rules that follow from the decision - No query parameters for city. `?city=phoenix` is not an architecture. - No city name in the service pillar URL. The pillar is `/ac-repair/`, city-agnostic. - Suburbs get their own intersection only when there is a real job to show. Otherwise they are a section on the nearest metro intersection page. - One slug convention, frozen before the first page ships. Changing it later is a redirect project, not an edit. - Service pillar links down to every city intersection. Every intersection links up to its pillar and across to its location hub. --- ## 2. Site architecture ``` HOMEPAGE / | +-- TIER 1 - SERVICE PILLARS (city-agnostic) | /ac-repair/ /ac-installation/ /furnace-repair/ /furnace-installation/ | /heat-pump-repair/ /heat-pump-installation/ /maintenance-plan/ | /indoor-air-quality/ | +-- TIER 2 - INTERSECTIONS (the money pages) | /ac-repair/phoenix/ /ac-repair/scottsdale/ /ac-repair/mesa/ | /furnace-repair/phoenix/ | ...one per service x city that has a project to show | +-- TIER 2b - LOCATION HUBS (indexes + local entity) | NOTE: for a SERVICE-AREA business (one address, many towns) these become | service-area pages carrying the city's project feed, and the site emits ONE | LocalBusiness at the real address. See annex section P. | /service-areas/ /service-areas/phoenix/ /service-areas/scottsdale/ | +-- TIER 3 - PROBLEM / SYMPTOM PAGES (top of funnel, urgent) | /ac-blowing-warm-air/ /ac-not-turning-on/ /furnace-wont-ignite/ | /ac-making-loud-noise/ /thermostat-not-responding/ | ...each references exactly one parent pillar | +-- TIER 2c - CONVERSION SURFACES (v4.1, annex section M) | /ac-installation/estimate/ /furnace-installation/estimate/ | /heat-pump-installation/estimate/ (sizing + tier, never a price) | /specials/ (singleton; offers as anchored sections) | /brands/[brand]/[model]/ (capped: 20 total, 5 per brand, | each gated on a real installed job) | +-- TIER 3b - COMPARISON / DECISION CLUSTER | /repair-vs-replace/ /heat-pump-vs-furnace/ /ac-size-guide/ | /seer-rating-guide/ | +-- TIER 3c - EDITORIAL (annex section O; optional for a small team) | /resources/[slug]/ seasonal, rebate, local code, community | ...one required parent service pillar each, unique topicKey, 600-word floor | +-- TIER 4 - PEOPLE, PROOF, TRUST /team/[name]/ /projects/ /projects/[slug]/ /reviews/ /about/ /financing/ /brands/ ``` --- ## 3. CMS content model and enforced internal linking Linking rules written in a document decay within months, because the person who wrote them stops being the person who publishes. Rules written as required CMS fields cannot decay, because a page will not save without them. | Content type | Required reference fields | What the template generates from them | |---|---|---| | **ServicePillar** | `city[]` (areas served) | An `ItemList` of every intersection page for this service. A block of every `ProblemPage` that references this pillar. A link to the matching comparison page. | | **Intersection** | `service` (1), `city` (1), `project` (min 1) | Breadcrumb, the up-link to its pillar, the across-link to its location hub, the project card with technician attribution, and its full JSON-LD block. | | **LocationHub** | `city` (1) | An index of every intersection in that city, the city's project feed, NAP and hours from the `LocalBusiness` record, GBP link. | | **ProblemPage** | `parentService` (exactly one) | A contextual link to the parent pillar using the exact service term, the pre-filled diagnostic widget, and the emergency CTA. | | **Project** | `technician`, `service`, `city`, `photos[]`, `gps` (all required) | Appends itself to the matching intersection page, location hub feed, and technician profile. No manual linking, ever. | | **Technician** | `credentials[]` | Profile page, the list of every project performed, and the `Person` node in the graph. | ### Anchor text rules, applied by template | From -> To | Anchor | |---|---| | Pillar -> intersection | Exact: *AC repair in Phoenix* | | Intersection -> problem | Symptom-rich partial: *AC blowing warm air* | | Problem -> pillar | Exact service term: *AC repair* | | Intersection -> location hub | City name: *Phoenix service area* | | Project -> technician | Technician name | ### Orphan detection (nightly job) A cron job walks the published set and logs any page with fewer than 3 inbound internal links. The list goes to the SEO lead as an automated alert, not a dashboard somebody has to remember to open. Orphans are the failure this architecture is most likely to produce, because the page count grows faster than anyone's attention. --- ## 4. The schema entity graph v3 listed schema types. Types alone describe nothing. What earns entity understanding is the `@id` references between them: this company employs this person, who performed this job, which was this service, at this location. ``` Organization @id: /#org |- sameAs ------> GBP profile, BBB, social profiles |- employs -----> Person @id: /team/mike-rodriguez/#person +- hasPart -----> LocalBusiness @id: /service-areas/phoenix/#localbusiness |- areaServed --> City (Phoenix) |- sameAs ------> GBP CID for that location +- makesOffer --> Service @id: /ac-repair/#service Person @id: /team/mike-rodriguez/#person |- worksFor -----> /#org |- hasCredential -> EducationalOccupationalCredential (NATE) # the Person-to-Project link is expressed as `author` on the Project node below, # not as performerIn: that property expects an Event, not a record of work. Project (typed Article) @id: /projects/phx-4412/#project |- location -----> /service-areas/phoenix/#localbusiness |- about --------> /ac-repair/#service +- author -------> /team/mike-rodriguez/#person Service @id: /ac-repair/#service +- provider -----> /service-areas/phoenix/#localbusiness (the city being served) ``` - **Per-location identity:** every location page gets its own `LocalBusiness` with a unique `@id`, its own NAP, `geo`, `openingHours`, and `sameAs` to that location's GBP CID. Not one shared business entity repeated. - **FAQ markup discipline:** `FAQPage` applies only to a discrete Q&A accordion block, never to a whole page because it happens to answer questions. Comparison pages use `Article` plus `HowTo` or `ItemList` for the decision framework, and add `FAQPage` over their 3 to 5 genuine Q&A pairs. Symptom pages get no FAQ markup at all. See annex section K.3. - **Built for extraction:** a TL;DR summary block sits at the top of problem and comparison pages. If an AI Overview is going to quote something, it should be the sentence you chose. The optional `SpeakableSpecification` markup goes on the page's `WebPage` node, and comes with an honest caveat: Google has only ever documented `speakable` for news publishers, so treat it as cheap positioning rather than a ranking feature, and never sell it to a client as one. Same for `HowTo`, which no longer produces rich results: use it where the content genuinely has steps, not for the snippet it used to earn. Note: the implementation annex corrects the project node to `Article`. See annex section C. --- ## 5. Featured Local Projects: the operating procedure **This is the module most likely to fail, and the failure is operational, not structural.** Most contractors are not content studios. A workflow that needs two hours of technician time per project ships four projects instead of forty, and every page with a required project field stays unpublished. **Step 1 - Field capture.** Technicians capture in CompanyCam or Fulcrum using a structured form, on site, before leaving. Required: 2 before photos (one wide, one close), 2 after photos, what was wrong, what we did, the part or brand, and the neighborhood or landmark. GPS is auto-captured in EXIF. This is non-negotiable and it is the moderation gate. **Step 2 - Moderation queue.** The content coordinator clears the queue daily. Reject anything missing GPS or a signed work order, no exceptions, because the whole claim of local proof rests on it. Accepted jobs create a `Project` node with status *Needs copy*. **Step 3 - Ghostwriting with real attribution.** An AI draft is generated from the captured fields; the technician does not write prose. The draft goes to the named technician on mobile: *Is this correct? Yes / No plus comments.* The timestamped approval is what legitimizes the byline. Without it, the byline is a lie and the E-E-A-T signal is worth less than nothing. **Step 4 - Publish and link.** On approval, the CMS hook appends the project to its intersection page, the city feed, and the technician profile, and adds its `@id` to the JSON-LD graph automatically. Nobody links anything by hand. The relationship is the link. ### Who owns what | Role | Owns | |---|---| | Field technicians | Photo capture and job data, on site. Nothing else. | | Content coordinator | Daily moderation, AI draft, chasing the fact-check, publishing. | | SEO lead | Schema QA, internal-link audit, Core Web Vitals monitoring, quarterly decay crawl. | | CSR / dispatcher | Google Business Profile Q&A inside a 24-hour SLA, and the weekly GBP post. | ### Capture path B: the client briefs the agency Path A above assumes the technician captures on site in CompanyCam or Fulcrum. That is the strongest evidence and the hardest habit to start. **Most agency clients already do something simpler: they tell you about the job.** A call, a text thread, a weekly catch-up. The work is being documented; it is just being documented to you instead of into a system. Build on that habit rather than replacing it. The delta is small and specific: | Already happening | The ask on top | |---|---| | Client describes the job | Job address or nearest cross street, and the date | | Mentions who did it | The technician's name, spelled the same way every time | | Sometimes sends a photo | **Two photos, original files, sent from the phone that took them** | **The EXIF trap, and it is the one that silently kills this.** WhatsApp, iMessage, Telegram, Slack and Facebook Messenger all strip location metadata and recompress on send. A photo that arrives through a chat thread has no GPS, so it fails the moderation gate while looking perfectly fine to the person who sent it. Either use an upload form that stamps geolocation at upload time, or ask for the original file through a link. Do not ask people to "send photos" through chat and then wonder why nothing passes. #### The Drive folder intake, and why it shrinks the ask A shared Google Drive folder is the practical answer to the EXIF trap. **Drive stores the original file**; chat apps do not. One folder per client, the crew drops photos in, and a watcher on the folder creates the draft. **This makes the ask on the crew smaller than it first appears.** If EXIF survives, the photo already carries the GPS coordinates and the timestamp — so nobody has to type an address or a date. The system derives the location and the day from the file, and the briefing that already happens supplies the only things a photo cannot know: which technician, what was wrong, what was done. Crew drops photos in Drive → watcher reads EXIF (GPS + timestamp) → derives city, matches to service area → creates a Project draft → the existing briefing fills the narrative → moderation queue, then publish **How photos must reach the folder, because the method decides whether any of this works:** - Upload the photo file from the camera roll through the Drive app. This preserves everything. - **Not** the Drive app's document scanner, which produces a flattened file with no camera metadata. - **Not** a screenshot of a photo, and **not** a photo re-shared out of a chat thread, which has already been stripped once. **Verify on day one with a single test photo, before assuming ninety days of capture.** If Location Services is turned off for the camera app on a crew phone, **every photo that phone ever takes will arrive without GPS**, and nothing in the workflow will announce it. That is a phone setting, not a process problem, and it is a two-minute fix if you find it on day one instead of day eighty-nine. The intake validates EXIF on arrival and replies when it is missing, so a silent failure becomes a visible one. **Two evidence grades, and the difference is stated rather than hidden:** - **Grade A** — GPS in EXIF, work order reference, technician approval. Unlocks everything: tier-1 eligibility, the full project claim, the technician byline. - **Grade B** — client-briefed job with address, date, named technician and photos without verified GPS, confirmed in writing by the client. Publishes the page, counts toward the 3-project minimum, and does **not** count toward tier-1 eligibility. **The byline rule holds in both grades.** If the agency writes the piece from a briefing, the byline does not claim a technician wrote or approved it unless that technician actually did. The page may say the work was performed by a named technician, because that is true. Attributing the *writing* to someone who never saw it is the one thing this whole evidence model exists to prevent. **The honest volume rule:** if the pipeline produces 10 projects a month, you get 10 project-backed intersection pages a month. Cut the page count, never the content floor. A hundred thin intersection pages is the doorway-page pattern that gets a site filtered; ten real ones is a moat competitors cannot copy without sending their own trucks out. --- ## 6. Problem pages and the zero-click problem Symptom pages target exactly the queries AI Overviews synthesize and answer in place. Plan for the click not to happen, and make the page worth publishing anyway. ### Required on every problem page - **Diagnostic tool, pre-populated** with the symptom the page is about. The visitor arrives mid-problem; the tool should already know which problem. - **Emergency CTA above the fold.** *Still not cooling? Schedule same-day service.* Phone number tappable, visible without scrolling. - **A secondary capture that is not a phone call** - a downloadable checklist that takes an email or mobile number, for the visitor who is troubleshooting but not ready to book. - **Remarketing pixel with search-bounceback detection:** fire a `search_bounceback` event when the referrer carries a search query and viewport time stays under 7 seconds. **Naming correction:** this is not zero-click. A true zero-click visitor never reaches the site at all and cannot be measured from it. This measures someone who arrived, did not find the answer fast enough, and went back — which is still real demand worth remarketing to, and still worth fixing the page for. ### Correction carried over from v3 v3 repositioned the diagnostic tool toward a low-intent *check-up* framing, which moves the tool away from the exact context where it converts. - **Problem pages:** urgent framing. Something is broken right now. The tool triages and hands off to a same-day booking. - **Homepage and maintenance pages:** check-up framing. Nothing is broken; the tool is a reason to engage. Same tool, two entry framings, chosen by page type in the template. --- ## 7. Page-level wireframes ### Intersection page - `/ac-repair/phoenix/` (new in v4) - H1: AC Repair in Phoenix, AZ - Above fold: phone (`tel:` link) + same-day booking CTA + trust row - Unique copy, 350-500 words: this service, in this city. Local conditions, response area, what the job usually involves here. - Featured Local Project, required, min 1: before/after photos, neighborhood, named technician, link to profile - Auto-generated links: up to the AC Repair pillar, across to the Phoenix service area, down to 3+ related problem pages - Reviews filtered to this city where volume allows - Schema: Service + LocalBusiness `@id` + BreadcrumbList + Project ### Service pillar - `/ac-repair/` - H1: AC Repair Services - What we fix: scope, process, what to expect, pricing approach without numbers - Cities we serve (ItemList): auto-generated link to every intersection page for this service - Common problems: auto-generated from every ProblemPage referencing this pillar - Decision support: link to `/repair-vs-replace/` - Schema: Service with `areaServed` array + FAQPage only if a real Q&A block exists ### Problem / symptom page - `/ac-blowing-warm-air/` - TL;DR summary block, speakable: the sentence you want quoted back by an AI Overview - Emergency CTA above fold: still not cooling? Same-day service. Tap to call. - Diagnostic tool, pre-filled, urgent framing - Causes ranked: what a homeowner can check, then what needs a technician - Secondary capture: downloadable checklist to email or SMS - Parent link: exact-match anchor to the AC Repair pillar - Schema: Article + `hasPart` HowTo + Speakable ### Location hub - `/service-areas/phoenix/` - H1: Plumbing Services in Phoenix, AZ - NAP + hours + map from the LocalBusiness record, unique `@id` - Index of services in this city: auto-generated links to every intersection - City project feed: every project performed in Phoenix, newest first - Technicians who cover this area (Person cards) - Schema: LocalBusiness `@id` + `sameAs` GBP CID + `geo` + `openingHours` ### Technician profile - `/team/mike-rodriguez/` - Photo, name, years in trade: a real person, photographed on a real job - Credentials: NATE, EPA, manufacturer certifications - Jobs performed: auto-populated from every approved Project - Schema: Person + `worksFor` + `hasCredential`. Projects link to the technician through `author` on the Project node; there is no valid inverse property to emit here. ### Comparison / decision page - `/repair-vs-replace/` - TL;DR verdict block, speakable: answer the question in the first screen - Decision framework: age, repair cost ratio, efficiency, refrigerant type - Comparison table, structured and extractable - Schema: Article + HowTo or ItemList. Not FAQPage for the whole page. --- ## 8. Technical defaults, enforced by the template These are not recommendations. The template blocks publishing when they are unmet. - **Meta description:** required field. Empty means the page cannot publish. - **Canonical:** self-referential by default, with a deliberate override only. - **Phone number:** above the fold on every template, as a `tel:` link with `contactPoint` schema. - **Hero image:** 100 KB or under, preloaded. - **Video:** lite-YouTube embed pattern. Never a raw iframe. - **Third-party widgets** (reviews, financing): lazy-loaded through IntersectionObserver. --- ## 9. Acceptance criteria by page type | Page type | Ships only when all of these are true | |---|---| | **Homepage** | Organization schema with `sameAs` to GBP, BBB, and social; speakable summary block; persistent phone in header; Lighthouse mobile performance 80+ | | **Service pillar** | Service schema with `areaServed` array; links to every city intersection; FAQ markup only where genuine Q&A exists; link to the relevant comparison page | | **Intersection** | 350-500 words unique to service x city; minimum 1 Featured Local Project with technician attribution; self-canonical; LocalBusiness `@id` reference; 3+ links to related problem pages | | **Location hub** | LocalBusiness schema with unique `@id`, NAP, `geo`, `openingHours`; `sameAs` to that location's GBP CID; index of every intersection in the city; at least one project in the feed | | **Problem page** | Article schema with `hasPart` -> HowTo; pre-filled diagnostic widget; emergency CTA above fold; exact-anchor link to parent pillar; speakable TL;DR | | **Technician profile** | Person schema with `worksFor` and `hasCredential`; relationship to every project performed; a bio with demonstrable experience, not adjectives | --- ## 10. Build sequence Ordered by dependency and gate, not by calendar. Each phase ends at a gate; the gate is what earns the next phase, and a gate that fails means the phase repeats rather than the schedule slipping. **Phase 1 - decisions frozen.** Slug conventions and canonical rules, written down and locked. Intersection convention `/[service]/[city]/` committed. City list committed. Canonical policy documented. Nothing is built yet, because every later phase encodes these choices and changing them afterward is a redirect project. *Gate: one document, approved, that a developer could hand to a second developer without explanation.* **Phase 2 - the content model.** CMS types, required reference fields, schema generators, auto-linking. Build the relationships from Section 3 as enforced fields. *Gate: a test intersection page cannot be saved without a service, a city, and a project attached, and its JSON-LD graph validates with every `@id` resolving.* **Phase 3 - templates.** Page templates with the publish-blocking defaults from Section 8, CWV guardrails, and the two diagnostic-tool framings wired per page type. *Gate: a page with an empty meta description is rejected by the CMS, and mobile Lighthouse performance clears 80 on the heaviest template.* **Phase 4 - the pilot, and the real test.** One city, two services, end to end, including field capture. This phase is not about the pages. It is about whether technicians actually document jobs on site. *Gate: projects arrive from the field without being chased, and the median capture takes minutes. If it does not clear, fix the capture workflow before scaling anything, and re-cut the page count to match true throughput.* **Phase 5 - rollout in sprints.** Remaining intersections, location hubs, problem pages, ordered by revenue per job, descending. *Gate per sprint: zero orphans in the nightly report, no CWV regression, every new intersection carrying its project.* **Phase 6 - the decay cycle turns on.** Triggered once the published set passes roughly 100 pages. A quarterly audit against Search Console; any page down more than 30% in clicks year over year gets flagged for refresh. *Gate: the first decay crawl runs and produces a refresh queue with named owners.* --- ## 11. Content maintenance | Content | Refresh trigger | |---|---| | Service pillars | Annual review, or whenever the service scope itself changes. | | Problem pages | Each major equipment generation, or when Search Console shows decay. | | Intersection pages | A new project replaces the featured one as soon as a better job comes in from that city. | | Location hubs | Quarterly project refresh at minimum. | | Comparison content | When efficiency standards, rebate programs, or pricing economics move materially. | | Technician profiles | When certifications change, and immediately when a technician leaves. A byline on a departed employee's project stays, with the profile marked accordingly. | --- Home Service Website Architecture, Implementation Specification v4.2. Supersedes v3. Fortitude Creative, September 2026. ================================================================ DOCUMENT 2 OF 2 ================================================================ # Implementation Annex — companion to the Home Service Website Architecture spec v4.2 Source: https://fc-build-spec-a5aaf279.vercel.app/annex.html Publisher: Fortitude Creative. Last updated 2026-09-25. THE DOCUMENT SET - Wireframe v4, the architecture: /wireframe-v4.md - This document, the build spec: /annex.md (sections A-L) - AI Visibility Runbook: /ai-visibility.md (off-site protocols, citation panel) - Operational Runbook: /runbook.md (deployment and maintenance) - Both core documents in one fetch: /llms-full.txt - Everything condensed under 4,000 chars: /brief.txt v4 froze the architecture. This annex is the layer beneath it: the conventions, validation rules, and gates an engineer needs in order to start without asking what anything meant. Two items in the handed-down spec would not have survived contact with a validator. Both are corrected here and marked. --- ## A. CMS data model | Content type | Required reference fields | Cardinality | Validation | |---|---|---|---| | `IntersectionPage` | `parentService` -> ServicePillar; `parentCity` -> LocationHub; `featuredProject` -> Project | 1:1, 1:1, 1:n (min 1) | Reject save if any empty. Reject if a published `IntersectionPage` already exists for the same (service, city) pair. | | `ProblemPage` | `parentService` -> ServicePillar; `topicKey` | 1:1; unique | Reject if empty. Exactly one parent, never two. Reject if another published ProblemPage already holds this `topicKey`. | | `EditorialPost` | `parentService` -> ServicePillar; `topicKey` | 1:1; unique | Reject if empty, if the `topicKey` is taken, or if body under 600 words. See section O. | | `Project` | `parentService` -> ServicePillar; `parentCity` -> LocationHub; `technician` -> Person | 1:1 each | Reject if any empty. Reject if `gps` or `workOrderRef` missing. Reject publish before `technicianApprovedAt` is set. | | `ServicePillar` | none, queries inverse relationships | — | Slug must match the locked service registry in section B. | | `LocationHub` | none, queries inverse relationships | — | Requires NAP, `geo`, `openingHours`, GBP CID before publish. | | `AuthorityClaims` (singleton) | Not a reference type. Holds `yearEstablished`, `licenses[]`, `serviceArea[]`, `certifications[]`, `fleetSize`, `employeeCount`, `awards[]`, `bbbRating`. **See K.1 for the full field definitions.** | 1 per site | Reject save on any empty required field. License numbers pass per-state format validation. No LocationHub publishes while the singleton is incomplete. Lives on the section I config singleton, not beside it. | | `EstimatorPage` | `parentService` -> ServicePillar; `outputTiers[]` (min 2); `sampleScenarios[]` (min 2) | 1:1; 2:n; 2:n | Reject if outputTiers < 2, ctaText or disclaimer empty, or body under 300 words. No currency field exists on this type (M.1). | | `EquipmentModelPage` | `parentBrand` -> BrandPage; `installedByTechnicians` -> Person[] | 1:1; 1:n | Four gates: brand in `installedBrands[]`, model in `approvedModels[]`, within `totalModelCap`/`perBrandModelCap`, and at least one Project references it. Reject under 600 words (M.2). | | `SpecialOffer` (repeatable on the /specials/ singleton) | `linkedService` -> ServicePillar (optional) | 0:1 | Reject save when `validUntil` is in the past. Nightly job archives expired offers and removes them from the render (M.3). | | `Person` | `credentials[]`, `sameAs[]`, `knowsAbout[]` | 0:n each | Profile cannot publish with zero projects attached. `sameAs` is optional at the Person level and required (min 2) on Organization; see K.4. | **Inverse queries drive every generated block.** A ServicePillar renders its city list by querying IntersectionPages that reference it; a LocationHub renders its service index the same way. Editors write copy. They do not build navigation, and they cannot override a generated link's target or anchor text. --- ## B. URL and slug conventions ### Locked rules - **Pattern:** `/[service]/[city]/`, service first, always. - **Trailing slash required.** Non-slash variants 301 to the slash form. - Lowercase, hyphen-separated, ASCII only. No stop words, no dates, no IDs. - **Singular service terms.** One convention, no exceptions, so nobody has to remember which pillar was plural. - **No query parameters for city,** no client-side city switching, no JS that swaps city content on one URL. Each intersection is server-rendered and independently crawlable. This is the rule the entire strategy rests on. ### Locked service registry Every pillar slug, frozen. Adding a service means adding to this list, not inventing a slug at publish time. ``` /ac-repair/ /ac-installation/ /furnace-repair/ /furnace-installation/ /heat-pump-repair/ /heat-pump-installation/ /maintenance-plan/ /indoor-air-quality/ ``` **Correction to v4 (applied):** the v4 sitemap showed `/heat-pumps/` and `/maintenance-plans/`, which violate the singular rule this annex locks. Both are corrected in the registry above and in the v4 document. Catching it now costs an edit; catching it after launch costs a redirect map. ### City disambiguation, decided now rather than at expansion - Default slug is the bare city name: `/ac-repair/phoenix/`. - When two cities in the registry share a name, **both** take a state suffix: `/ac-repair/portland-or/` and `/ac-repair/portland-me/`. Never suffix only the newcomer, because that silently changes the meaning of the original URL and leaves the older page ranking for an ambiguous term. - The city registry includes planned expansion markets, not just live ones. A collision is detected against the plan, so the suffix is decided before either page exists. - The slug generator enforces this on save. It is not a naming guideline. --- ## C. JSON-LD entity graph ### Correction to the handed-down example (would have failed validation) The supplied graph typed each project as `HowTo` and gave it `performer` and `locationCreated`. `HowTo` has no `performer` property, so that reference is dropped on parse, and Google retired HowTo rich results for most surfaces, so the type buys nothing while inviting a mismatched-markup signal. A completed job is a record of work, not a set of instructions for the reader. **Corrected below:** projects are typed `Article` with `author`, `about`, and `contentLocation`, which are real properties that resolve. Reserve `HowTo` for problem-page content that genuinely walks a reader through steps. ```json { "@context": "https://schema.org", "@graph": [ { "@type": "Organization", "@id": "https://example.com/#org", "name": "Example HVAC Inc.", "url": "https://example.com", "logo": "https://example.com/logo.png", "sameAs": ["", "", ""] }, { "@type": "LocalBusiness", "@id": "https://example.com/service-areas/phoenix/#localbusiness", "name": "Example HVAC Inc. - Phoenix", "parentOrganization": { "@id": "https://example.com/#org" }, "areaServed": { "@type": "City", "name": "Phoenix", "addressRegion": "AZ" }, "telephone": "+1-555-123-4567", "address": { "@type": "PostalAddress", "addressLocality": "Phoenix", "addressRegion": "AZ" }, "geo": { "@type": "GeoCoordinates", "latitude": 33.4484, "longitude": -112.0740 }, "openingHoursSpecification": [ "/* per day */" ], "sameAs": "" }, { "@type": "Service", "@id": "https://example.com/ac-repair/#service", "name": "AC Repair", "serviceType": "HVAC Repair", "provider": { "@id": "https://example.com/service-areas/phoenix/#localbusiness" } }, { "@type": "Person", "@id": "https://example.com/team/mike-rodriguez/#person", "name": "Mike Rodriguez", "worksFor": { "@id": "https://example.com/#org" }, "hasCredential": { "@type": "EducationalOccupationalCredential", "credentialCategory": "NATE Certification" } }, { "@type": "Article", "@id": "https://example.com/projects/capacitor-replacement-ahwatukee/#project", "headline": "AC Capacitor Replacement in Ahwatukee", "about": { "@id": "https://example.com/ac-repair/#service" }, "author": { "@id": "https://example.com/team/mike-rodriguez/#person" }, "publisher": { "@id": "https://example.com/#org" }, "contentLocation": { "@id": "https://example.com/service-areas/phoenix/#localbusiness" }, "datePublished": "2026-09-25", "image": ["", ""] } ] } ``` **Emission rule:** every page emits its own slice of this graph, and every `@id` it references must resolve to a node emitted somewhere on the site. Validate against the Rich Results test before launch, and again in CI on every template change. --- ## D. Anchor text conventions | Source | Target | Anchor | |---|---|---| | Service pillar | Intersection page | Exact: *AC Repair in Phoenix* | | Intersection page | Problem page | Symptom-rich partial: *AC blowing warm air* | | Problem page | Service pillar | Exact service term: *AC Repair* | | Project | Service pillar | Exact service term | | Project | Technician profile | Full name: *Mike Rodriguez* | | Intersection page | Location hub | City name: *Phoenix service area* | Templates generate all of these. Manual override is disabled in the editor. One generated link per target per page, so a page never carries the same anchor to the same URL three times. --- ## E. Template defaults - **Meta description:** required; publish blocked when empty. - **Canonical:** self-referential by default, override only alongside an explicit redirect. - **Phone:** above fold, `tel:` link, present in `contactPoint` schema, pulled from the singleton in section I. - **Breadcrumb:** rendered and marked up on every template except the homepage. - **Title tag:** required, and constrained. Target 30-60 characters; **warn above 60, fail above 70**, fail when empty, and fail when it duplicates another title anywhere on the site. Patterns are set per page type and generated, not typed: intersection pages use `{Service} in {City} | {Business}`, which also satisfies the citation-durability rule in K.7. *Why this is now explicit:* a competitor crawl found branch pages carrying title tags of 491 to 536 characters. Nothing in a CMS prevents that by default, the page still validates, and the title is the single most visible piece of text a search result has. This spec required a meta description from the start and never constrained the title. That was an omission. - **Hero image:** 100 KB or under, WebP, `preload` hint in ``, explicit width and height to hold layout. - **Video:** lite-YouTube facade. No third-party JS on load. - **Third-party widgets** (reviews, financing, chat): lazy-loaded through `IntersectionObserver`, never render-blocking. - **Fonts:** self-hosted, `font-display: swap`, subset. ### Page-type template rules - **LocationHub order is fixed:** H1, then the machine-readable About block (K.2), then NAP, hours and map, then the service index, then the project feed. The About block sits above NAP because it is the first prose an extractor reaches, and it should be the summary you wrote to be quoted rather than an address. Its data source is the AuthorityClaims singleton plus that hub's own city fields. - **The ProblemPage template does not import the FAQ block component at all.** Absence in the template is the first line of defence; the CI gate in section F is the one that actually enforces it (K.3). - **Intersection templates** render the H1 and opening sentence from the citation-durability pattern in K.7, both carrying business name and city. --- ## F. CI and QA gates ### The build fails when - Any template renders without a meta description, self-canonical, phone number, breadcrumb, or JSON-LD block. - Any `@id` reference in an emitted graph does not resolve to a node on the site. - Lighthouse mobile performance scores under 80 on any of the six template archetypes. - Cumulative Layout Shift exceeds 0.1 on simulated mobile. - A slug fails the section B validator. - **No-JS crawl gate (K.6):** an intersection page renders under 400 bytes of body text with JavaScript disabled, or emits its JSON-LD client-side. Test: `curl -s -L | wc -c` against the extracted body text. - **Date freshness (K.5):** an article-type page is missing `dateModified`, or carries one older than 365 days. - **Entity corroboration (K.4):** `Organization.sameAs` has fewer than 2 entries. Person profiles are not gated. - **FAQ scope (K.3):** `FAQPage` schema is detected on any page whose content type is `ProblemPage`. This is the enforcement of record for the FAQ rule; the template omitting the component is a convention, and conventions lose to a future import. - **Alt text (section H):** any rendered `` is missing its `alt` *attribute*. An explicitly empty `alt=""` passes only on an image also marked `role="presentation"` or `aria-hidden="true"`, because empty alt is the correct markup for a decorative image and forcing a description onto one makes the screen-reader experience worse, not better. - **License expiry (K.1):** any license in the AuthorityClaims singleton is past its `expires` date. - **Title tag:** missing, over 70 characters, or duplicated elsewhere on the site. - **Topic collision:** two published pages of the same content type share a `topicKey` (see below). - **G.5 redirect integrity:** a new 301 whose source URL did not exist in the previous crawl, or a source mapping to more than one target. Volume caps are per action type — CONSOLIDATE 100, manual addition 10 — and exceeding a cap triggers mandatory human review rather than automatic failure, because a large legitimate consolidation should be looked at, not blocked. - **Cost rule (M.0):** a dollar amount (`$` followed by a digit, or "N dollars") or a banned positioning word (affordable, cheap, cheapest, budget-friendly, bargain) appears in any rendered page body. - **Estimator no-JS content (M.1):** an estimator page returns under 300 words with JavaScript disabled, *or* is missing `` with 2 or more rows. Both conditions are checked, because word count alone passes a page of prose with no answer in it. - **Model exemption expiry (M.2):** a model page whose `exemptionExpiresAt` has passed still has zero inbound Project references. The nightly job unpublishes it, sets `robots: noindex, nofollow`, removes it from `/sitemap-model.xml`, and alerts content plus the SEO Lead. - **Review counter integrity (M.4):** `aggregateRating.reviewCount` is lower than the floored number shown in the header. ### The nightly crawl reports - Orphan pages, meaning fewer than 3 inbound internal links. - Core Web Vitals regressions against the previous run. - 4xx and 5xx responses. - Schema validation failures against the structured data spec. - Intersection pages published without a live featured project. - **Sitemap hygiene:** any URL in a sitemap that returns a non-200, or that carries `noindex`. A sitemap is a list of pages you are asking to have indexed; a `noindex` page in it is a contradiction you are sending to a crawler on purpose. - **Near-duplicate topics:** pages of the same type whose H1 and opening 200 words exceed a 0.8 similarity score. Reported, never gated — near-duplication is a judgement call, and a threshold that blocks publishing would be wrong as often as it was right. **Warning, never a failure:** `/llms.txt` or `/llms-full.txt` missing, or out of sync with the current architecture. Lint it, ship anyway. **Standing upgrade trigger:** if Google, OpenAI, Anthropic, or Perplexity formally documents programmatic use of `llms.txt` or an equivalent convention, this becomes a hard failure on all branches immediately, with no further deliberation required (K.8). *Declined twice, on the same grounds: failing production deploys on a missing `llms.txt`. Blocking a real release over a file that no retrieval system has documented using inverts the risk, and the second request restated the ask without answering that objection. The trigger above is the part worth keeping, and it fires the day the evidence exists.* ### Tiered content floors The 350-word absolute minimum from section 1 does not move: nothing publishes below it, ever. The tiers below sit *above* that floor and set the target for a market's priority. | Tier | IntersectionPage | LocationHub | |---|---|---| | tier1 | 1,500 words | 3,000 words | | tier2 | 800 words | 1,500 words | | tier3 (default) | 500 words | 750 words | - **Assignment:** `contentTier` on the LocationHub, defaulting to tier3. The SEO Lead owns the initial mapping across all locations as a one-time strategic pass; marketing ops then executes against the assigned tiers. - **Tier-1 eligibility:** a location needs **6 or more published Projects** with photos, geotagged to its service area, before tier-1 can be assigned. Tier-1 asks for 1,500 words per intersection page, and without the job history behind it that target is met with padding, which is the failure mode the whole content floor exists to prevent. - **Override:** tier-1 may be assigned without the project threshold for an aggressive new-market launch, with named approval and the justification written into the commit message. - **Scope of the count:** narrative copy only. Promo badge text, financing terms and other conversion module strings do not count toward it, or the floor becomes satisfiable with furniture. - **Enforcement:** CI warns below the tier floor. The deploy proceeds only when the commit message carries `--ack-short-copy={pageSlug}` for each flagged page, which makes shipping thin copy a deliberate, attributable act rather than a silent one. - **Tracking:** the nightly dashboard flags acknowledgements older than 30 days. - **At 90 days below floor:** the page is recommended for soft-unlink from global navigation, requiring the same named approval as the GBP tier-3 escalation in M.5. It stays published and indexable. **Hard word-count gates are rejected permanently**, and the directive's own reasoning is the right one: blocking a page that could earn reviews and links until it reaches a word count removes the mechanism that makes the content investment pay. Gates run on pull requests against the six template archetypes, not against every published page. Page-level problems are the nightly crawl's job; template-level problems must never reach production. --- ## G. Operational rules **Minimum viable launch footprint:** top 1 to 2 services x top 1 to 2 cities, each with a live Featured Project. Do not wait for grid coverage. An unpublished intersection costs nothing; a thin one damages the cluster it sits in. - **Project reuse:** a project may appear on its location hub (one of many) and on its service pillar (in a recent-work block). It may appear on **exactly one** intersection page. No reuse across intersections, because the same job in two cities is the first lie a reviewer would catch. - **GBP SOP stands as written in v3:** weekly posts, Q&A answered inside 24 hours, photo cadence matched to the project pipeline. - **Content supply governs page count.** The grid will land below its theoretical maximum. That is the expected outcome, not a shortfall to explain away. - **Minimum project count before a city opens:** 3 documented projects in that service area. One project is a page; three is a pattern a reviewer believes. A city with fewer stays unpublished until the trucks have been there. - **Before any city expansion:** the capture workflow is running with field operations, and ongoing project documentation has a named owner. Expansion that outruns capture produces exactly the thin grid this architecture exists to avoid. --- ## H. Accessibility - Descriptive `alt` text on every meaningful image. Before-and-after project photos describe the equipment and the condition, not "image1". - Accordion and FAQ components carry `aria-expanded` and are keyboard operable. - Captions on all video content. - Body text contrast at 4.5:1 minimum. - Visible focus states on every interactive element, including the sticky phone CTA. --- ## I. Brand governance These values live in one config singleton (CMS singleton or environment, one of the two, chosen once). Templates read from it. Editors cannot fork them, because a second phone number in the wild is a tracked-call attribution failure and a NAP inconsistency at the same time. | Key | Used by | |---|---| | `primaryPhone` | Header, every CTA, `contactPoint` schema, `tel:` links | | `napFormat` | Footer, location hubs, LocalBusiness schema, GBP parity checks | | `gbpCid` per location | `sameAs` on each LocalBusiness node | | `legalName` | Organization schema, footer, financing disclosures | | authority claim fields (K.1) | The About block, Organization and LocalBusiness schema, trust rows. Same singleton, same rules. | | `gbpQualityThresholds` | `minReviews`, `minRating`. Read by the nightly report in L.1. Operations tunes them without a deploy. | | `aiReferrerDomains[]` | The assistant referrer list in L.3. Analytics reads it; quarterly review updates it. | | `llmCrawlerRegex[]` | The user-agent patterns in L.4. CI compiles each one. | | `estimatorParamsAllowlist` | Querystring parameters the estimator may accept; everything else 301s to canonical (M.1). | | `installedBrands[]`, `approvedModels[]` | The brand registry and the demand-gated model list, maintained quarterly by the SEO Lead (M.2). | | `totalModelCap`, `perBrandModelCap` | 20 and 5. Enforced in CMS validation (M.2). | | `allowFinancingTerms` | Default false. Financing blocks state availability and link out; numeric credit terms require legal sign-off and a recorded decision (N.6). | | `contentTier` per LocationHub | tier1/tier2/tier3, default tier3. Sets the word-count target above the 350-word absolute floor (section F). | | `allowOfferAmounts` | Default false. Dollar amounts on specials are off per the cost rule; enabling it per client is a recorded decision (M.3). | | `reviewCountDisplay` | Header format, floor rule, and the schema-exactness requirement for the review counter (M.4). | | `kRequirementStatus` | Per-requirement `required` or `optional`. Changed only in a pull request that also changes this document, so config and prose cannot drift apart (L.6). | --- ## J. Zero-click event schema (closes the flagged gap) The handed-down spec left remarketing instrumentation "for sprint tickets." Leaving it undefined is how it ships as three inconsistent event names. Defined here instead. | Event | Fires when | Parameters | |---|---|---| | `search_bounceback` | Referrer carries a search query and viewport time stays under 7 seconds. **Not zero-click:** a true zero-click visitor never reaches the site and cannot be measured from it. This is a return-to-search, which is still worth remarketing to. | `page_type`, `page_slug`, `dwell_ms` | | `diagnostic_start` | Diagnostic tool receives its first input | `page_type`, `symptom`, `framing` (urgent or checkup) | | `diagnostic_complete` | Tool reaches a recommendation | `symptom`, `outcome`, `steps_completed` | | `emergency_cta_click` | Above-fold urgent CTA tapped | `page_type`, `page_slug`, `service`, `city` | | `checklist_download` | Secondary capture submitted | `page_slug`, `capture_type` (email or sms) | | `call_initiated` | `tel:` link activated | `page_type`, `service`, `city`, `position` (header, hero, footer) | Every event carries `service` and `city` where the page type has them, so remarketing audiences can be built per intersection rather than per site. Pixel placement is a template concern, not a page concern: it ships once in the base layout. --- ## K. AI retrieval optimization Everything above optimizes for search engines reading the site, including their AI features. This section covers the other half: being quotable when someone asks ChatGPT, Perplexity, or Claude for an electrician in their city. That traffic never appears as a ranking, and the page that earns it is built differently. ### Two reconciliations, so the spec does not contradict itself **One singleton, not two.** The authority claims below extend the existing config singleton in section I. They do not create a second one. A site with two sources of truth for business facts has none. **FAQ markup is not loosened.** Section C still bans `FAQPage` on a whole page. K.3 wraps only the discrete Q&A block on a comparison page, and only when the questions are real. That is the same rule applied, not an exception to it. ### K.1 Authority claims, added to the section I singleton Verifiable business facts every template can pull from. Editors cannot fork them. A LocationHub cannot publish while any required field is empty. | Field | Notes | |---|---| | `yearEstablished` | The year, not "over 20 years." A year survives being quoted a decade later; a relative claim rots. | | `serviceArea[]` | Cities and ZIP codes served. Feeds `areaServed` and the city registry in section B. | | `licenses[]` | Array of objects, one per license, so acquisitions with two licenses in one state and staggered renewals both fit: `{ state, type, number, expires }`. Example: `{ "state": "AZ", "type": "ROC", "number": "123456", "expires": "2027-06-30" }`. **Format validation:** a per-state regex map checks the number on save; a state absent from the map does not block publishing, the field saves marked *unverified format* and goes to the Compliance Lead. **Expiry is a gate, not a display field:** publishing with an expired license fails the build, and the nightly job flags any license inside 60 days of expiring. | | `certifications[]` | NATE-certified technician count, manufacturer authorizations. | | `fleetSize`, `employeeCount` | Capacity signals. Numbers, kept current. | | `awards[]` | Each with a date. An undated award is unquotable. | | `bbbRating` | Rating plus the profile URL for `sameAs`. | **Deployment coupling in the regex map, named rather than hidden.** The per-state format map lives in code, so a new state needs a DevOps change before its numbers validate. Moving the map into the CMS so a Compliance Lead could edit it is the wrong trade: a regex typed into a text field can be silently over-permissive, and `.*` passes every check while validating nothing. Validation you cannot trust is worse than none, because it reads as verified. So the map stays in code, the Compliance Lead owns its contents and files the DevOps request, and an unmapped state never blocks a launch: the license saves as *unverified format* and goes to manual review, because a whole market waiting on a regex ticket is a worse failure than a typo in a license number. **One field name, deliberately not the one that was asked for.** The review asked for `yearsInBusiness`. This spec stores `yearEstablished` instead. A count of years is correct on the day it is typed and wrong every day after, and nothing in the build will ever tell you it went stale. A year is a fact that stays true, and "years in business" is a one-line computation from it at render time. Store the fact, derive the phrasing. **Pending values, and why they need a shape.** A build almost always starts before the client sends the certificate. The wrong answer is a plausible-looking placeholder, because a plausible placeholder is one merge away from being published as a real licence number. The rule: - A pending entry is `{ state, type, number: "PENDING-VERIFICATION", expires: null, status: "pending" }`. It is obviously not a licence number, and the format validator recognises the sentinel rather than trying to parse it. - **The template omits the licence line entirely while status is pending.** It never renders "PENDING-VERIFICATION", and never renders a partial claim. Absent beats wrong. - The build does not fail, so work continues, but **no page making a licensure claim publishes** while the sentinel is in place. - **The placeholder itself expires.** Pending for more than 30 days escalates to the Compliance Lead, because a placeholder with no expiry is how "temporary" becomes permanent and a field nobody remembers stays empty for a year. **These are legal claims, not copy.** A license number or certification count published wrong is a regulatory problem, not an SEO problem. The singleton needs an owner who verifies each value against the source document, and a review whenever a license renews. Wire the fields; do not invent the values. ### K.2 Machine-readable About block Every LocationHub renders a 150 to 200 word structured summary in third person, directly below the H1, generated from the singleton plus that location's fields. It is factual, quotable prose written to be extracted, not marketing copy. > [Business Name] has served the [City/Region] area since [Year], providing residential heating, > ventilation, and air conditioning services. Licensed in [State] ([License > Type] #[Number]), the company employs [N] NATE-certified technicians and specializes in [primary > services]. [Business Name] is an authorized [Manufacturer] dealer and maintains > [credential/rating]. Third person throughout. "We have served" cannot be quoted by a model answering someone else's question; "[Business Name] has served" can. ### K.3 FAQPage schema on comparison pages only - Comparison and decision pages (repair vs replace, heat pump vs furnace, AC size guide) carry `FAQPage` wrapping **3 to 5** genuine Q&A pairs. - Each question is a real question someone asks, not a heading rewritten with a question mark. Each answer is self-contained and readable with no page context around it. - **Problem and symptom pages do not get FAQ schema.** They are single-intent diagnostic content; adding Q&A markup creates circular structure and dilutes the topical focus that makes them rank. ### K.4 Person schema, extended | Field | Requirement | |---|---| | `Organization.sameAs[]` | **Required, minimum 2.** The GBP profile URL counts as one. Populate the rest from the verified aggregator list the NAP audit produces. This is the entity the assistants are actually trying to identify. | | `Person.sameAs[]` | **Optional.** Downgraded from required: a residential technician's LinkedIn is thin corroboration, and gating every profile on it stalls the build for the least certain payoff in section K. The field stays available and is worth filling for a lead tech with a real professional footprint. | | `hasCredential[]` | Structured credentials: NATE certification, state license, manufacturer training. | | `knowsAbout[]` | References to the ServicePillar pages this person is genuinely expert in. | `sameAs` is what turns a name on a page into an entity a model can corroborate elsewhere. It carries its weight at the Organization level, where the GBP profile and the verified aggregator listings do real identification work. At the Person level it was demoted after review: requiring a findable professional profile for every residential technician stalls the build for the least certain payoff in section K. ### K.5 Article dating, enforced (freshness hygiene, not an AI signal) Stated honestly: this is a freshness signal for traditional crawlers and a forcing function against content decay. Its effect on LLM retrieval is assumed, not demonstrated. It stays required because content rot is real; it should stop being sold as an AI optimization. - Every article-type page (problem pages, comparison and decision pages) emits `datePublished` and `dateModified` in its JSON-LD. - **CI gate:** the build fails when `dateModified` is missing, or older than 365 days, on any article-type page. - `dateModified` changes only when the content actually changed. A build that touches the date without touching the substance is lying to the crawler, and the date-freshness gate becomes a ritual instead of a signal. ### K.6 Crawl accessibility gate - Every intersection page renders **400 bytes or more of body text with JavaScript disabled**. - JSON-LD is present in the raw HTML response, never injected client-side. - **Test method:** a plain `curl` fetch, or a headless browser in no-JS mode, during build validation. The build fails on any page that does not clear it. Most assistant fetchers do not execute JavaScript. A page that needs JS to show its content is, to them, an empty page. ### K.7 Citation durability The H1 and the first sentence of body copy on every intersection page carry the business name and the city: - **H1:** AC Repair in Phoenix | [Business Name] - **First sentence:** "[Business Name] provides 24/7 AC repair service throughout the Phoenix metropolitan area..." Models quote sentences and drop links. If the identifying information is inside the sentence, the attribution survives the citation being stripped. ### K.8 LLM accessibility files `/llms.txt` indexing the key pages in plain English, and `/llms-full.txt` carrying the full text for a single fetch. Regenerate whenever the architecture changes or a new service area launches. **Status:** both files are already live on this document site, which is also the working proof of the pattern. Honest caveat, carried over from the source spec: their utility for third-party crawlers is plausible and unproven at scale. Cheap to ship, so ship them; do not count on them. ### K.9 Crawler licensing stance Decide explicitly whether AI systems may cache and redistribute the content, rather than letting silence pick for you. For a local service business the answer is nearly always **permissive**: the entire goal is to be quoted to someone shopping for a contractor, and a restriction that keeps you out of an answer costs a lead to buy nothing. - **Permissive:** a `License:` line in the `/llms.txt` header. - **Restrictive:** documented in `robots.txt` or the emerging `ai-permissions.txt` convention. This is a client decision, made per client and recorded in the build, not a default anyone should inherit silently. ### K.10 Content supply, stated plainly > Content supply is an operational constraint external to this specification. CMS gates prevent > publishing intersection pages without Featured Projects; they cannot generate those projects. The > business operations layer must deliver project content at the volume the architecture assumes. ### K.11 Speakable markup **Required, enforced as a warning.** - **Scope:** LocationHub and IntersectionPage. - **Target:** the page's `WebPage` node. **Corrected from the directive**, which targets `LocalBusiness` and `Service`. Google has only ever documented `speakable` for `Article` and `WebPage` in news-publisher contexts, and never for `LocalBusiness` or `Service`, so emitting it on those two types is markup for a combination that has never been supported anywhere. If it is worth emitting at all, emit it on the type where it is at least defined. - **Value:** a CSS selector pointing at the introductory paragraph, the first two or three sentences. - **Constraint:** the selected text resolves to something non-empty and stays at or under 300 characters. - **Enforcement:** CI warns when the selector is missing, resolves empty, or exceeds the limit. It does not fail the build, because a selector that stops matching after a template edit is a content problem, not a broken deploy. - **Deprecation clause:** drop the requirement if Google formally deprecates `speakable` for LocalBusiness. Worth knowing now: `speakable` support has only ever been documented for news content, so treat this as cheap positioning rather than a mechanism with evidence behind it. ### Acceptance criteria - AI retrieval complete - Every LocationHub renders the About block, pulling from the singleton. - Every Person entity has at least one validated `sameAs`. - Every comparison page carries valid `FAQPage` schema over genuine Q&A pairs, and no symptom page carries any. - CI passes the no-JS render gate and the date-freshness gate. - No intersection page publishes without its Featured Local Project. ### Build order Slotted into the existing phases from v4, by dependency rather than sprint number: - **With the content model (v4 phase 2):** authority fields on the singleton, extended Person type, About block component. - **With the templates (v4 phase 3):** the no-JS and date-freshness CI gates, citation-durability H1 and opening sentence, FAQ schema on comparison templates. - **After launch:** the LLM accessibility files, regenerated on every architecture change. --- ## L. Off-site authority and measurement **Scope, stated plainly, replacing any percentage anyone quotes.** Section K covers on-site preparation for AI retrieval. It is necessary and not sufficient. When an assistant answers "best AC repair in Phoenix," it assembles that answer mostly from off-site surfaces: Google Business Profile, review platforms, aggregator listings, local listicles, forum threads. Section L gates publication on minimum off-site readiness and builds the loop that measures whether any of this works. The AI Visibility Runbook (https://fc-build-spec-a5aaf279.vercel.app/ai-visibility.md) holds the human protocols that generate that authority. ### L.0 The runbook is a build dependency The build fails when `/ai-visibility.md` or its `runbook.yaml` is missing, or when `runbook.yaml` has a null `owner` or `reviewCadence`. Off-site work is the half that quietly does not happen; tying it to the build makes its absence loud. ### L.1 GBP identity required, GBP quality measured (gate rejected) **Required fields on LocationHub:** `gbpCid` and `gbpPlaceId`. A location hub cannot publish without them, because they are the join between this site and the surface the assistants actually read. **What was asked for, and why it is not implemented.** The review asked that publishing be blocked when a location's Google rating is under 4.5 or its review count under 20. Three reasons that gate does not ship: - **It inverts cause and effect.** A new location has 8 reviews precisely because it has no web presence yet. Refusing to publish its page removes the thing that generates reviews. - **It hands a third party a veto over publishing.** A Places API outage, a quota exhaustion, or a billing lapse means nobody can publish anything. Build systems must not depend on a live external API to emit a page. - **It fires on noise.** A 4.49 average blocks the release; one new five-star review unblocks it. That is not a quality gate, it is a coin toss with a cron job. **Implemented instead:** the thresholds live in the section I singleton as `gbpQualityThresholds.minReviews` and `.minRating`, the nightly job reads the Places API and reports every location against them, and a location below threshold ships with a recorded acknowledgement naming who accepted it. Measured, visible, owned. Never a publish block. ### L.2 NAP verification freshness - **Required field on LocationHub:** `napLastVerifiedAt`. - **Creating a new LocationHub** is blocked when the timestamp is null or older than 90 days. - **Updating an existing one** warns but never blocks. Otherwise a stale audit stops you fixing a typo, and the fix people reach for is faking the timestamp. - The nightly job flags anything past 60 days so the audit is scheduled, not discovered. **Why 90 and not 30:** the review asked for 30 days while its own runbook sets the NAP audit at quarterly. Ninety matches the cadence that is actually staffed. A gate tuned tighter than the process behind it does not raise the standard, it teaches people to bypass the gate. ### L.3 AI referrer tagging Analytics tags traffic arriving from assistant platforms as `channel=ai_generated`, with the domain list in the section I singleton as `aiReferrerDomains[]`: ``` chatgpt.com perplexity.ai claude.ai gemini.google.com copilot.microsoft.com you.com ``` **One domain removed from the supplied list.** `bing.com` is not on it. Bing Copilot and ordinary Bing organic search share that referrer, so including it files a large volume of plain search traffic as AI-generated. The measurement exists to tell you whether AI is sending anyone; a list that cannot distinguish the two produces a number that always looks encouraging and means nothing. `copilot.microsoft.com` and `claude.ai` are added in its place. **Deployment test:** request the homepage with a spoofed `Referer` from each listed domain and assert the beacon fires with `channel=ai_generated`. The deployment fails if it does not. ### L.4 Assistant crawler logging Middleware matches incoming User-Agent headers against `llmCrawlerRegex[]` in the singleton and logs hits: `GPTBot`, `anthropic-ai`, `ClaudeBot`, `Claude-User`, `Google-Extended`, `PerplexityBot`, `CCBot`, `OAI-SearchBot`. - CI compiles every pattern; a syntax error fails the build. - An unmatched user agent exceeding 1,000 requests a day emits a warning naming it, so the list grows from observation rather than memory. - Quarterly review of the list sits in the runbook. This is the cheapest honest signal in section L: it tells you whether assistants fetch the site at all, which is a precondition for every other claim here. ### L.5 Citation panel data The measurement table exists before launch, and CI asserts its schema. Storage is vendor-agnostic: BigQuery, Postgres, or Supabase all qualify. The schema does not. ``` run_id STRING timestamp TIMESTAMP assistant STRING // chatgpt, perplexity, gemini, claude, copilot query STRING appeared BOOLEAN link_present BOOLEAN sentiment STRING // nullable: positive, neutral, negative, hallucination snapshot_url STRING // nullable: path to stored response or its hash ``` How the table is filled is the runbook's problem. That it exists, and that nothing ships before it does, is this spec's problem. ### L.6 Falsifiability, without letting the spec rewrite itself (automation rejected) The review asked for quarterly automated correlation between each K requirement and `appeared=true`, auto-demoting any requirement showing no positive correlation for two quarters. - **There is no variance to correlate against.** Every page implements every K requirement, by construction, because the CMS refuses to publish otherwise. A variable that never varies has no correlation with anything. The computation is not hard, it is undefined. - **The sample cannot carry the weight.** Ten queries across five assistants, monthly, is roughly 150 observations a quarter, confounded by ranking changes, review volume, competitor activity, and model updates nobody outside the labs can see. - **The consequence is silent.** A spec that edits its own requirements from a statistic produces a document nobody can trust to still say what it said last quarter. **Implemented instead:** the quarterly review is real and scheduled, and it asks the answerable question, which is not "does K.2 correlate with appearing" but *what did the assistants actually cite when we appeared, and when we did not.* That reads the sources, which is where the answer lives. A human proposes demotions with evidence, and a demotion is a normal documented change to this spec, made by a person who signs it. A requirement demoted this way moves to `optional` in `kRequirementStatus` in the singleton, and the change lands in this document in the same pull request. Config and prose never disagree. --- ## M. v4.1 surfaces: estimator, equipment pages, specials, review counter Three new page types and one global element. Each inherits the same envelope as everything else here: server-rendered, gated on real supply, measured. None of it changes the section L conclusion that off-site authority dominates AI citation on local commercial queries. These are conversion and traditional-SEO surfaces that must not become the thin-content vector the rest of this spec exists to prevent. ### M.0 The cost rule, binding on everything in this section **No dollar amounts anywhere in client content. Not a price, not a range, not an hourly or flat rate.** Also banned as positioning: affordable, cheap, cheapest, budget-friendly, bargain. What is allowed, and actively wanted: cost *intent* and cost *factors*. Pages may target "cost to replace AC in [city]" and should. They answer with what drives the number — labor complexity, equipment access, system age, efficiency tier, fuel type, emergency versus scheduled, repair versus replacement — and close with "we provide a detailed estimate after assessing the job." This is not a stylistic preference. Published numbers commoditize the offer and undercut the sales conversation before a technician has seen the home. **CI gate:** the build fails when a dollar amount (`$` followed by a digit, or "N dollars") or a banned positioning word appears in the rendered body of any page. This gate is what keeps the rule alive after the person who wrote it stops reviewing every page. ### M.1 Installation-intent estimator **Locked URLs:** ``` /ac-installation/estimate/ /furnace-installation/estimate/ /heat-pump-installation/estimate/ ``` **What it estimates: equipment, not price.** This is a sizing and tier recommender. It takes home size, system age, efficiency preference and fuel type, and returns a capacity range, an efficiency band, and an equipment tier recommendation, then hands off to a real quote. It never returns a number with a currency symbol in front of it (M.0). That is not a weakened version of the tool. A homeowner searching "cost to replace AC" wants to know what they are buying and what moves the number; the quote itself requires seeing the house, which is also the honest answer and the one that books the appointment. **Architecture: content page first, calculator second.** - At least 300 words of substantive text, server-rendered, visible with JavaScript disabled: why an estimate matters, the factors the tool weighs, a sample table of 2 to 3 common scenarios with their tier recommendations, the CTA, the disclaimer. - The interactive calculator loads as progressive enhancement, after first paint. - A `