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. ← Back to Wireframe v4
| Content type | Required reference fields | Cardinality | Validation |
|---|---|---|---|
IntersectionPage |
parentService → ServicePillarparentCity → LocationHubfeaturedProject → 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 → ServicePillarparentCity → LocationHubtechnician → 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. |
AuthorityClaimssingleton |
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[]; sampleScenarios[] |
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 registered, model demand-approved, within caps, and at least one Project references it. Reject under 600 words (M.2). |
SpecialOfferrepeatable on /specials/ |
linkedService → ServicePillar (optional) |
0:1 | Reject save when validUntil is already past. The 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.
/[service]/[city]/, service first, always.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/
/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.
/ac-repair/phoenix/./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.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.
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.
{
"@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": ["<GBP profile URL>", "<BBB URL>", "<social URLs>"]
},
{
"@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": "<GBP URL carrying this location's CID>"
},
{
"@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"
}
},
{
/* CORRECTED: Article, not HowTo. author/about/contentLocation resolve. */
"@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": ["<before>", "<after>"]
}
]
}
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.
| 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.
tel: link, present in contactPoint schema, pulled from the singleton in section I.{Service} in {City} | {Business}, which also satisfies K.7.preload hint in <head>, explicit width and height to hold layout.IntersectionObserver, never render-blocking.font-display: swap, subset.@id reference in an emitted graph does not resolve to a node on the site.curl -s -L <url> | wc -c against the extracted body text.dateModified, or carries one older than 365 days.Organization.sameAs has fewer than 2 entries. Person profiles are not gated.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.<img> 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.expires date.topicKey (section O).$ followed by a digit, or "N dollars") or a banned positioning word appears in any rendered page body.<table class="sample-ranges"> with 2 or more rows. Both conditions are checked, because word count alone passes a page of prose with no answer in it, and the sample table is the part that answers a planning-intent query without JavaScript.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.aggregateRating.reviewCount is lower than the floored number shown in the header.noindex. A sitemap is a list of pages you are asking to have indexed; a noindex page in it is a contradiction sent to a crawler on purpose.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.
The 350-word absolute minimum from section 1 does not move: nothing publishes below it, ever. These tiers 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 |
contentTier on the LocationHub, default 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.--ack-short-copy={pageSlug} for each flagged page. Shipping thin copy becomes deliberate and attributable rather than silent.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.
alt text on every meaningful image. Before-and-after project photos describe the equipment and the condition, not "image1".aria-expanded and are keyboard operable.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 need 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). |
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 |
|---|---|---|
sg_click | Referrer carries a search query and viewport time stays under 7 seconds | 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.
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.
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.
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, is marked unverified format, and goes to the Compliance Lead for manual check. See the deployment note below. 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. A site advertising a lapsed license number is a regulatory problem that no one would otherwise notice until a customer did. |
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. |
.* passes every check while validating nothing. Validation you cannot trust is worse than none, because it reads as verified.
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.
{ state, type, number: "PENDING-VERIFICATION", expires: null, status: "pending" } — obviously not a licence number, and the format validator recognises the sentinel rather than trying to parse it.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.
Third person throughout. "We have served" cannot be quoted by a model answering someone else's question; "[Business Name] has served" can.
FAQPage wrapping 3 to 5 genuine Q&A pairs.| 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. |
The sameAs requirement is the one with teeth. It is what turns a name on a page into an entity a model can corroborate somewhere else,
and it is the field most likely to stall the build, because it needs a real profile per technician. Budget for that before promising the field is required.
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.
datePublished and dateModified in its JSON-LD.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.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.
The H1 and the first sentence of body copy on every intersection page carry the business name and the city:
Models quote sentences and drop links. If the identifying information is inside the sentence, the attribution survives the citation being stripped.
/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.
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.
License: line in the /llms.txt header.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.
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, never for LocalBusiness or Service, so emitting it there is markup for a combination that has never been supported anywhere. If it is worth emitting at all, emit it where it is at least defined.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.sameAs.FAQPage schema over genuine Q&A pairs, and no symptom page carries any.Slotted into the existing phases from v4, by dependency rather than sprint number:
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.
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.
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.
napLastVerifiedAt.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.
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
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.
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.
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.
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.
appeared=true, auto-demoting any requirement showing no positive correlation for two quarters.
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.
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. These are conversion and traditional-SEO surfaces that must not become the thin-content vector the rest of this spec exists to prevent.
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 or a banned positioning word appears in any rendered page body. That gate is what keeps the rule alive after the person who wrote it stops reviewing every page.
/ac-installation/estimate/ /furnace-installation/estimate/ /heat-pump-installation/estimate/
<noscript> block repeating the sample table.estimatorParamsAllowlist: [homeSize, systemAge, efficiency]. Every other querystring parameter 301s to canonical, so the tool cannot spawn an index of near-duplicate pages.SoftwareApplication for the calculator. FAQPage only over a genuine Q&A block — a sample table is a table, and marking it up as an FAQ is the exact misuse K.3 bans.EstimatorRangeViewed server-side on render, EstimatorCalculated client-side on submit./brands/[brand-slug]/[model-number]/, trailing slash, brand drawn from the installedBrands[] registry.
| Gate | Blocks publication unless |
|---|---|
| 1. Strategic CMS pre-save | the parent brand's slug is in installedBrands[]. |
| 2. Demand runbook-driven | the model is in approvedModels[], each entry carrying brand, model, monthlySearchVolume, dataSource, lastUpdated. A model with no recorded demand gets no page. |
| 3. Capacity CMS pre-save | it stays within totalModelCap: 20 and perBrandModelCap: 5. |
| 4. Operational CMS pre-publish | at least one published Project references the model via equipmentInstalled. The SEO Lead may grant a logged exemption recorded as exemptionGrantedBy, exemptionGrantedAt, and exemptionExpiresAt (auto-set to granted + 90 days). On expiry with no Project, the nightly job unpublishes the page, sets noindex, nofollow, and drops it from the model sitemap. |
Gate 4 is the one that matters. Same rule as intersection pages: a page about equipment you have never installed is a page about nothing.
/sitemap-model.xml with changefreq: yearly, referenced from the index and excluded from the primary sitemap.Offer placeholder WOULD MISSTATE THE PRICE
price: 0 does not read as "price on request", it states the equipment is free. And priceType: ContactPoint is not a valid value — priceType takes a price-type enumeration, while ContactPoint is a schema type.
Emit Product with brand and model, no Offer node. An offer appears only when a real published price exists, which M.0 forbids anyway.
/specials/, a singleton. Offers are anchored sections. No per-coupon URLs, because a coupon URL is a page that expires into a 404 or a thin orphan.Offer schema with validThrough.offerTitle, offerDescription, validFrom, validUntil, conditions, optional linkedService. Reject save when validUntil is already past. The nightly job archives expired offers.allowOfferAmounts: false in the singleton; enabling it for a client who insists is a recorded decision by Jason, not a developer's call.★ 4.9 — {floor_to_100}+ Google Reviews, so 437 renders as "400+".aggregateRating.reviewCount carries the exact live number, never the floored one. CI fails when the schema value is below the displayed floor. Header "400+" with schema 347 fails; with schema 437 it passes.#seo-alerts, owned by the SEO Lead with DevOps as backup, 8 business-hour SLA. What it freezes is the number, not the pipeline: the counter holds at the last known good value and the new figure cannot ship until a human picks one of three resolutions — accept and document, legitimate drop so rebaseline, or data error so keep last known good. Deployments continue.fail_closed_with_retry on the publish gate THIRD-PARTY VETO, AGAIN
The directive blocks publishing when the Places API cannot be reached. Same objection as the L.1 gate, for the same reason: a build must never depend on a live third-party API to emit a page. An expired billing card at Google would stop every release.
blockedScope: production_only on the negative-delta alert. A blocked production deploy path is how a security fix ends up waiting on a marketing metric.
The legitimate goal — never silently publishing a wrong review count — is fully served by freezing the value and requiring a human to accept it. Freeze the number, not the release train.
Replaces the rejected publish gate. Nothing here blocks a deployment; consequences escalate over time instead, and a new location gets 90 days before any of it applies.
gbpQualityMonitor: coldStartGracePeriodDays: 90 onApiError: fail_open_with_warn thresholds: large_metro: { minReviews: 50, minRating: 4.5 } mid_market: { minReviews: 20, minRating: 4.5 } small_market: { minReviews: 10, minRating: 4.3 } cityTierAssignments: phoenix: large_metro scottsdale: large_metro flagstaff: small_market # unassigned cities default to mid_market escalations: tier1: { consecutiveDaysBelow: 7, action: slack_warn, channel: '#seo-alerts', coolDownDays: 7 } tier2: { consecutiveDaysBelow: 30, action: page_marketing_ops } tier3: { consecutiveDaysBelow: 90, action: recommend_soft_unlink, requires: marketingDirectorApproval } # timers reset when both metrics meet threshold
suppressFromOrgSchema: true is removed entirely.
Dropping a location's LocalBusiness node from the graph does not quietly reduce its prominence — it orphans every @id that points at it.
Intersection pages reference /service-areas/[city]/#localbusiness by design (section C), so suppressing the node leaves those references pointing at nothing,
and the section F gate that fails the build on an unresolved @id would fail on the next deploy. The proposed remedy breaks the entity graph it is meant to protect.
Soft-unlink, when approved, removes the location from primary and footer navigation while the URL stays live and indexable for review generation. Its @id node stays in the graph. Approval records acknowledgedBy, reason (20 characters minimum), and remediationTargetDate.
All three enter the L.6 loop at launch, tagged channel=estimator, channel=model_page, channel=specials.
| Surface | Tracked |
|---|---|
| Estimator | EstimatorCalculated events, and attribution from landing through to a booked appointment |
| Model pages | Organic entrances, time on page, appeared=true in the citation panel for model-specific queries |
| Specials | Referral clicks, and coupon redemption where it is trackable at all |
The automation that was wanted, pointed at the right target: it produces a shortlist for a human instead of editing the spec. Two tracks, because a surface with one quarter of data cannot be judged the same way as one with a history.
-- surfaces with 2+ quarters of data FLAG WHERE citations_current_q = 0 AND entrances_current_q < 0.7 * entrances_prev_q; -- surfaces with less than 2 quarters FLAG WHERE citations_current_q = 0 AND assisted_conversions = 0 AND entrances_current_q < 50;
Output is a board the SEO Lead reads. Flagged items get interpreted, demotions get proposed with the evidence attached, and the change lands as a pull request updating this document and kRequirementStatus together. Looker is one way to render it; any dashboard will do, and a spreadsheet will do for one client.
ProjectContentType:
supportsBulkApiCreate: true
endpoint: POST /cms/api/v1/projects?draft=true
auth: service-account token "field-capture-bridge"
rateLimit: 100 req/min, burst 25
Drafts created this way still pass through the section 5 moderation queue: GPS and work order required, technician approval before publish. The endpoint removes the typing, not the gate. Typical wiring is job-close webhook to a small function to a CMS draft, then the content coordinator's daily queue.
<HeroConversion> component, extend the nightly GBP sync to write {rating, reviewCount, lastUpdated} to the review cache, deploy templates and job, then flip to active and tag v4.2. Activating gates before the templates exist produces a frozen, unbuildable spec.
LocationHubPage and ServiceLocationPage. This spec has called those LocationHub and IntersectionPage since section A, and the names below stay this spec's.
A gate may depend only on inputs the team controls.
That one line explains every acceptance and every rejection across v4.1 and v4.2, and it is worth stating once rather than re-litigating per item.
gbpPlaceId is our data. Gate it.Gates on our own inputs make the team better. Gates on someone else's metrics make the team hostage.
A gate may depend only on inputs the team controls, and must treat transient external failures as non-blocking while still failing on permanent defects. For any CI check touching an external resource:
| Response | Action | Why |
|---|---|---|
| 2xx / 3xx | pass | working |
| 4xx | fail | a permanent defect in team-authored data. A 404 on a link we wrote is our bug, not Google's outage. |
| 5xx or timeout | warn | transient external condition |
That distinction is the part my original phrasing missed. "External" is not one category: a broken URL we authored is ours to fix inside a single deploy cycle, while an unreachable third-party API is not. Gates block on defects remediable within one deploy; everything else alerts. This supersedes any conflicting language in this section.
gbpPlaceId from section A, via the nightly GBP sync job. Hard fail when it is missing: our field, our configuration error.{reviewCount, rating, lastUpdated}, and for a new branch with no reviews it explicitly writes reviewCount: 0 — otherwise an empty state and a cache miss look identical at render time, and they call for opposite handling.| State | Condition | Copy |
|---|---|---|
| fresh | cacheAge ≤ 7d AND reviewCount ≥ 5 | ★ {rating} ({reviewCount} Google reviews) |
| lowVolume | 0 < reviewCount < 5 | ★ {rating} ({reviewCount} Google reviews) — see our Google profile |
| empty | reviewCount = 0 AND cacheAge ≤ 7d | New location — be the first to review us, with GBP link |
| stale | cacheAge > 7d | Review score updating… View Google profile, with link |
lowVolume and empty to the same string, "be the first to review us." That sentence is false at 1 through 4 reviews, and a visitor who just left one is being told nobody has.
Separated above: at zero, invite the first review; at one to four, show the real count, which is honest and still converts.
Alerting: #seo-alerts fires on stale only. lowVolume and empty are legitimate states for a new branch, and paging someone about a young location every night is how an alert channel becomes wallpaper.
inline_widget or modal_link.<noscript><a href="tel:{branchPhone}">Call to book</a></noscript>.booking_iframe_load_error; alert when the failure rate passes 1% for a location.Governed by a managerEndorsementRequired boolean in page front-matter, defaulted by the CMS:
| Page type and tier | Default |
|---|---|
| LocationHub, all tiers | true |
| IntersectionPage, tier-1 | true |
| IntersectionPage, tier-2 | false, soft-warn if missing |
| IntersectionPage, tier-3 | false, no warning |
personRef points at the same Person type as technician profiles.OfferBadge[], may be empty. Fields: text (≤50 chars), expires, cityScoped. Badges auto-suppress once expired.Cache-Control: max-age=3600, so an expired promo leaves the edge cache within the hour.allowOfferAmounts.TrustBadge[], may be empty: icon, label, linkUrl. CI warns when a label contains "guarantee" or "warranty" and linkUrl is empty. An unsubstantiated warranty claim with nothing behind it is the kind of sentence that gets read back to you later.
apr, duration and minAmount. Three problems:
minAmount is a dollar figure and an APR is a cost number.generic carries availabilityText and applyCtaUrl; specific carries apr, duration, minAmount, legalReviewed and applyCtaUrl. Compliance fields throughout: stateOverrides[], complianceJurisdiction (ISO-3166-2), disclosureText.
legalReviewed checkbox together, so nobody fills in an APR and leaves the boolean false without seeing it.
applyCtaUrl: HEAD request, 3-second timeout, per N.0 — 2xx/3xx passes, 4xx fails because a broken link we authored is our defect, 5xx or timeout warns because the lender's server being down is not.
legalReviewed is false, the build fails. The directive asked for a warning. Unreviewed credit terms on a live page are a legal exposure, not a style issue, and it is our own field, so N.0 permits the gate.
Counted within the primary content container only. Links inside <nav>, the global header and <footer> are excluded, via a configurable contentRegionSelector per template, defaulting to main.
That scoping is the whole point: a mega-nav carries 300+ links by design, and counting them in every page's budget produces a site-wide warning that masks the body-content bloat the check exists to find.
Warn at 150 links in the content region; the nightly report flags 200 or more. No build failure — a page can legitimately be link-dense, and this is a smell, not a defect.
width and height. Soft fail.loading="lazy" and fetchpriority="low". The page shell emits Cache-Control: max-age=3600 and widget behaviour is decoupled from page cacheability, so a slow widget never makes the page uncacheable.Every CTA anchor these templates render carries role="button" where it behaves as one, and a meaningful aria-label. CI fails when either is missing on a conversion CTA. Our markup, our gate.
Three separate reviews found the same hole: this spec had nowhere to put an article that is neither a symptom page nor a comparison. Seasonal content, rebate and incentive news, community coverage, equipment guidance that does not fit a head-to-head — all of it was homeless. Homeless content is how a blog ends up with 320 posts, 93 of them linking to no service page at all, and two separate articles about AC blowing warm air.
The choice was to ban the category or to govern it. Banning it loses the seasonal and local content that earns links, so it is governed.
/resources/[slug]/. If a client already runs /blog/, keep theirs and freeze it — what matters is that exactly one convention exists, not which word it uses.parentService, exactly one, the same rule that governs problem pages. This single field stops the orphan drift, because the template generates the link up to the pillar and the pillar lists its posts back by inverse query.topicKey, unique across published posts. The second article on the same topic cannot be saved; it has to become an edit to the first.datePublished and dateModified, both required, under the K.5 freshness gate.A post ships only if it is about this market, this equipment, or this season. If the same article could appear unchanged on a competitor site in another state, it is national filler, and national filler is what the content mills already produce at volume and for free.
When the near-duplicate report flags a pair, the resolution is a merge, never quiet coexistence: the stronger page absorbs what the weaker one had, the weaker 301s to it, and the topicKey moves across. A blog that only ever accumulates is how a site ends up competing with itself for its own terms.
Sections A through O assume a multi-location business: Phoenix, Scottsdale and Mesa each with a real address, real hours and its own Google listing, each getting its own LocalBusiness node.
LocalBusiness nodes carrying forty addresses it does not occupy.
That is fabricated location data, and among the faster ways to get a local business into trouble.
The test: would Google verify a listing at that address? Staffed premises, its own hours, its own phone, signage. If yes it is a location. If the answer is "we send a van there", it is a service area, and no amount of wanting it to rank makes it a location.
A business can be both: two real branches, each serving twenty surrounding towns. Then each branch is a LocalBusiness and each town it merely serves is a service area of the nearest one.
LocalBusiness node per real address, at the real address.areaServed[] on that node lists every town served.@id. They emit no LocalBusiness of their own, no NAP, no openingHours, no geo.Service.provider points at the one LocalBusiness. Every @id still resolves, so the section F graph gate passes unchanged./[service]/[city]/, self-canonical, project-gated./service-areas/[city]/, carrying the city's project feed, the services offered there, response expectations, and links to that city's intersections. An index and a proof page, not a fake branch page./service-areas/ indexes every town.gbpCid and gbpPlaceId live on the single LocalBusiness record, not on each city page.A service-area business almost always arrives with flat URLs: /arlington/ for towns and /ev-charging-stations/ for services, both at the root, both predating any convention.
/[city]/ 301s to /service-areas/[city]/./service-areas/ index, not to an intersection page that cannot publish yet. Redirecting a weak page into a nonexistent one loses traffic you did not have to lose.Every gate up to here protects the crawler, the entity graph or the business. None of them protect the person on the page. A site can pass all of section F and still bury the phone number below a hero carousel, or read like it was written for a keyword rather than a homeowner. This section closes that gap, and all of it is team-controlled, so N.0 permits gating on it.
| Rule | Enforcement |
|---|---|
| At 375px, a phone link or booking button is visible within the first 600px of scroll | fail staging, warn production for 30 days |
| At 1280px, at least one real customer review is visible without scrolling | fail staging, warn production |
| A booking form asks for 6 fields or fewer before submission | fail staging |
Project photo alt text includes the city or the service | warn |
The six-field rule is the one that will be argued about. Every extra field costs completions, and the fields that get added are almost always for the business rather than the customer. Name, phone, address, and what is wrong is four. A seventh needs someone to say out loud which field earns more than it costs.
contentLint: maxGradeLevel: 8.0 # Flesch-Kincaid maxKeywordDensity: 3.0 # percent, per 100 words bannedPhrases: - "leading provider of" - "best in class" - "100% satisfaction guaranteed" - "your trusted partner" enforcement: warn # override with <!--lint-disable:reason-->
Warn rather than fail, because grade level and density are heuristics and a copywriter with a reason should win. The override requires a reason in the comment, which makes the exception visible in review instead of invisible in a config file.
Monitored, not hard-failed, until a baseline exists. A budget set before measurement is a guess that generates noise.
performanceBudget: lcpDesktop: 1.2s lcpMobile4G: 1.8s totalKBGzipped: 150 thirdPartyScripts: 2 # maximum accessibility: standard: WCAG 2.1 AA hardFail: - body text contrast below 4.5:1 - focus ring missing on an interactive element warn: - skip-nav link absent
Two third-party scripts is a real constraint, and it is usually spent before anyone checks: a booking widget, a chat bubble, a review carousel, an analytics tag and a heatmap is five. Decide which two earn it.
Two things live here. First, the rules that were implemented in the build before they were written down, which is a divergence this section closes. Second, image governance, which is new.
Honest accounting. Each of these runs in the All Spark build today and had no home in the specification until now.
| Rule | Where it runs | Belongs to |
|---|---|---|
isPhysicalBranch conditional on LocationHub | template selection | Section P |
| DOM node budget: warn above 2,500, fail above 4,000 | CI gate | Section Q |
Internal anchor budget: warn above 90, fail above 120, scoped to main | CI gate | Section Q |
data-placeholder attribute discipline | template render + CI | this section |
Offers validUntil, 7-day minimum, auto-archive | specials module | Section N |
| Orphan detection, fewer than 3 inbound content links | CI gate | Section F |
| Face detection with a review queue | image pipeline | this section |
| Disclaimer partial on symptom pages | template partial | this section |
| Phone numbers in E.164 | template + CI | Section Q |
Service.provider bound to the site's LocalBusiness | schema emitter | Section C |
The budget counts links inside main only. Navigation, header and footer are excluded. A mega-nav carries a hundred links by design, and counting them turns a body-content check into a site-wide alarm that everyone learns to ignore.
On production builds, a primary nav link to a page that would render only an empty state is hidden. Applies to Recent Work, which needs at least one published project, and Specials, which needs at least one active offer.
An honest empty state is correct once a visitor is on the page. Sending them there from the main navigation is not honesty, it is a dead end with a signpost. On staging the links stay visible so the structure can be reviewed.
Adapt the trade and the licence noun per vertical. The reason is not legal theatre: these pages describe what a homeowner can safely check, and the boundary between that and what needs a professional is the most important sentence on the page.
outline: 0 without a visible :focus-visible replacement. A removed focus ring is a page that cannot be operated from a keyboard.padding-bottom: env(safe-area-inset-bottom) so an iOS home indicator does not sit on top of the call button.Every project photograph is taken inside or outside a customer's home. That is the whole value of the evidence model and it is also its main risk.
Redaction before publication. Faces, licence plates and visible house numbers are blurred unless there is explicit consent on file. A technician's own portrait is a separate case with its own consent.
person_consent is not true, the build raises a warning, not a failure — false positives on technician portraits are common, and a hard fail would train people to disable the check.Build the queue before requesting the first photograph from a client. A warning with no interface behind it is a warning nobody receives.
Service node carries provider pointing at the site's LocalBusiness.LocalBusiness.image is a raster, 1200x630 or larger, WebP or JPEG. An SVG logo is not an image for this purpose and rich results will ignore it.AggregateRating carries ratingValue, reviewCount, bestRating and worstRating, and the count matches what the profile shows. The header may display a floored number; the schema carries the exact one.tel: hrefs use E.164 with the country code.Sprint zero opens when all five are true:
Deployment and maintenance procedures that sit outside this spec live in the operational runbook.