Implementation Annex — companion to the v4.2 architecture spec

Build Spec: Home Service Site Architecture

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

The rest of the set Wireframe v4 (architecture) · AI Visibility Runbook · Operational Runbook
Plain text: /annex.md (this document) · /wireframe-v4.md · /llms-full.txt (both docs in one fetch) · /brief.txt (under 4,000 chars) · /llms.txt

ACMS data model

Content typeRequired reference fieldsCardinalityValidation
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[]; 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).
SpecialOffer
repeatable 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.

BURL 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

CJSON-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.
{
  "@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.

DAnchor text conventions

SourceTargetAnchor
Service pillarIntersection pageExact: AC Repair in Phoenix
Intersection pageProblem pageSymptom-rich partial: AC blowing warm air
Problem pageService pillarExact service term: AC Repair
ProjectService pillarExact service term
ProjectTechnician profileFull name: Mike Rodriguez
Intersection pageLocation hubCity 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.

ETemplate 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, fail when it duplicates another title on the site. Patterns are generated per page type, not typed: intersection pages use {Service} in {City} | {Business}, which also satisfies K.7.
  • Hero image: 100 KB or under, WebP, preload hint in <head>, 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.
Why the title rule is now explicit GAP FOUND IN REVIEW 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.

Page-type template rules

FCI 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 <url> | 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 <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.
  • 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 (section O).
  • 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 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 <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.
  • 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 returning a non-200, or carrying 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.
  • Near-duplicate topics: same-type pages whose H1 and opening 200 words exceed 0.8 similarity. Reported, never gated — near-duplication is a judgement call, and a threshold that blocked publishing would be wrong as often as 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. These tiers sit above that floor and set the target for a market's priority.

TierIntersectionPageLocationHub
tier11,500 words3,000 words
tier2800 words1,500 words
tier3 (default)500 words750 words
Hard word-count gates are rejected permanently 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.

GOperational rules

Minimum viable launch footprint Top 1 to 2 services × 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.

HAccessibility

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

KeyUsed by
primaryPhoneHeader, every CTA, contactPoint schema, tel: links
napFormatFooter, location hubs, LocalBusiness schema, GBP parity checks
gbpCid per locationsameAs on each LocalBusiness node
legalNameOrganization schema, footer, financing disclosures
authority claim fields (K.1)The About block, Organization and LocalBusiness schema, trust rows. Same singleton, same rules.
gbpQualityThresholdsminReviews, 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.
estimatorParamsAllowlistQuerystring 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, perBrandModelCap20 and 5. Enforced in CMS validation (M.2).
allowFinancingTermsDefault false. Financing blocks state availability and link out; numeric credit terms need legal sign-off and a recorded decision (N.6).
contentTier per LocationHubtier1/tier2/tier3, default tier3. Sets the word-count target above the 350-word absolute floor (section F).
allowOfferAmountsDefault false. Dollar amounts on specials are off per the cost rule; enabling it per client is a recorded decision (M.3).
reviewCountDisplayHeader format, floor rule, and the schema-exactness requirement for the review counter (M.4).
kRequirementStatusPer-requirement required or optional. Changed only in a pull request that also changes this document, so config and prose cannot drift apart (L.6).

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

EventFires whenParameters
sg_clickReferrer carries a search query and viewport time stays under 7 secondspage_type, page_slug, dwell_ms
diagnostic_startDiagnostic tool receives its first inputpage_type, symptom, framing (urgent or checkup)
diagnostic_completeTool reaches a recommendationsymptom, outcome, steps_completed
emergency_cta_clickAbove-fold urgent CTA tappedpage_type, page_slug, service, city
checklist_downloadSecondary capture submittedpage_slug, capture_type (email or sms)
call_initiatedtel: link activatedpage_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.

KAI 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 READ FIRST 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.

FieldNotes
yearEstablishedThe 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, employeeCountCapacity signals. Numbers, kept current.
awards[]Each with a date. An undated award is unquotable.
bbbRatingRating plus the profile URL for sameAs.
Deployment coupling in the regex map — named, not hidden The per-state format map lives in code, so a new state needs a DevOps change before its numbers validate. It was tempting to move the map into the CMS so a Compliance Lead could edit it, and that 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 rule is: the map stays in code. Compliance Lead owns its contents and files the DevOps request when a state is added. 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 DECIDED 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.
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

K.4 — Person schema, extended

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

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.

K.6 — Crawl accessibility gate

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:

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.

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)

Acceptance criteria — AI retrieval complete

Build order

Slotted into the existing phases from v4, by dependency rather than sprint number:

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

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

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 proposed auto-downgrade cannot work, and should not run if it could 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. 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.

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

M.1 — Installation-intent estimator

/ac-installation/estimate/ /furnace-installation/estimate/ /heat-pump-installation/estimate/

It estimates equipment, not price RESHAPED BY M.0 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 a tier recommendation, then hands off to a real quote.

That is not a weakened 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 both the honest answer and the one that books the appointment.

M.2 — Equipment and model pages

/brands/[brand-slug]/[model-number]/, trailing slash, brand drawn from the installedBrands[] registry.

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

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

M.3 — Specials page

M.4 — Sitewide review counter

Caution on the markup, not the display A business marking up its own aggregate rating on its own site is self-serving review markup, and Google does not grant review rich results for it. Display the counter, because it converts. Expect no rich result, and do not let anyone report it as an SEO win.
Rejected: 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.

Implemented instead: the counter renders from the last known good value, staleness warns at 7 days and escalates to the SEO Lead at 14. A page always ships.

Same answer to 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.

M.5 — GBP quality monitor

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
Two changes to tier 3 ONE OF THEM BREAKS OUR OWN CI 1. 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.

2. The nav de-link becomes a recommendation requiring approval, not an automatic action. Withdrawing internal links from a location because its Google rating is low makes that location harder to find, which makes it harder to win customers, which makes it harder to earn reviews. That is the same inversion as the original publish gate, running on a 90-day timer instead of instantly. Tier 3 raises it to a named human with the evidence attached; a person decides whether this location is genuinely a liability or simply young in a small market.

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.

M.6 — Measurement for the new surfaces

All three enter the L.6 loop at launch, tagged channel=estimator, channel=model_page, channel=specials.

SurfaceTracked
EstimatorEstimatorCalculated events, and attribution from landing through to a booked appointment
Model pagesOrganic entrances, time on page, appeared=true in the citation panel for model-specific queries
SpecialsReferral clicks, and coupon redemption where it is trackable at all
On applying the downgrade rule here — a partial concession My objection to L.6 was that universal requirements have no variance to correlate against: every page carries every K requirement, so there is nothing to compare. That objection does not apply to these surfaces. Model pages exist for some models and not others, the estimator for three services and not the rest. That is real variance against a real outcome, so the correlation is computable here, and worth computing.

What stays is the signature. "Without ceremony" is the part I decline, and only that part: a spec that quietly edits its own requirements is a document nobody can trust to still say what it said last quarter. The ceremony is one pull request.

M.7 — The quarterly variance report

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.

M.8 — Project bulk-create endpoint

The highest-leverage item in this specification Every gate here depends on Projects arriving, and the binding constraint has never been the architecture — it is whether a technician documents a job. This endpoint is what lets the job-close event in the field service tool create the draft, so nobody has to learn the CMS to feed it.
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.

NConversion and trust signals DRAFT

Lettered N, not M The directive called this "Section M", which is already the v4.1 surfaces. Two sections with one letter is how a spec starts contradicting itself in code review.

Status is draft, and no gate here is active yet. Per the staged activation sequence: merge the spec, build the <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.

Vocabulary: the directive says LocationHubPage and ServiceLocationPage. This spec has called those LocationHub and IntersectionPage since section A, and the names below stay this spec's.

N.0 — The rule behind every gate decision in this document

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.

  • A missing gbpPlaceId is our data. Gate it.
  • A missing manager quote is our content. Gate it.
  • A missing phone fallback is our markup. Gate it.
  • An unreachable Places API, a Google rating, a review count, a competitor's activity — none of these are ours. Alert, report, escalate to a human. Never let them decide whether a build ships.

Gates on our own inputs make the team better. Gates on someone else's metrics make the team hostage.

Refinement, adopted from review and better than the original wording

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:

ResponseActionWhy
2xx / 3xxpassworking
4xxfaila permanent defect in team-authored data. A 404 on a link we wrote is our bug, not Google's outage.
5xx or timeoutwarntransient 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.

N.1 — ReviewAggregate, required

StateConditionCopy
freshcacheAge ≤ 7d AND reviewCount ≥ 5★ {rating} ({reviewCount} Google reviews)
lowVolume0 < reviewCount < 5★ {rating} ({reviewCount} Google reviews) — see our Google profile
emptyreviewCount = 0 AND cacheAge ≤ 7dNew location — be the first to review us, with GBP link
stalecacheAge > 7dReview score updating… View Google profile, with link
Copy correction APPLIED The directive routes 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.

Correction: "build fails if review data unfetchable" is removed The directive specifies both a graceful fallback for stale data and a build failure for unfetchable data. Those cannot both be right: if the page has a defined rendering for the absence of fresh data, the build never needs to fail over it. The fallback is the better half, and it is what ships. A Places API outage degrades the badge and pages someone; it does not stop a release (N.0).

N.2 — BookingIntegration, required

N.3 — LocalManagerEndorsement

Governed by a managerEndorsementRequired boolean in page front-matter, defaulted by the CMS:

Page type and tierDefault
LocationHub, all tierstrue
IntersectionPage, tier-1true
IntersectionPage, tier-2false, soft-warn if missing
IntersectionPage, tier-3false, no warning

N.4 — OfferBadgeSet (template required, content optional)

N.5 — TrustBadgeSet (template required, content optional)

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.

N.6 — FinancingBlock, reshaped by the cost rule and by lending law

Financing terms carry no numbers by default The directive's fields include apr, duration and minAmount. Three problems: Two mutually exclusive modes, generic recommended in every case: generic carries availabilityText and applyCtaUrl; specific carries apr, duration, minAmount, legalReviewed and applyCtaUrl. Compliance fields throughout: stateOverrides[], complianceJurisdiction (ISO-3166-2), disclosureText.

CMS: mutually exclusive radio buttons. Selecting "specific" exposes the numeric fields and the legalReviewed checkbox together, so nobody fills in an APR and leaves the boolean false without seeing it.

URL validation on 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.

One gate stronger than requested: if any numeric term is present and 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.

N.7 — InternalLinkBudget, soft only

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.

N.8 — Performance instrumentation, advisory

Reconciliation with section F Section F already fails the build on CLS above 0.1 and Lighthouse mobile below 80, measured against the six template archetypes. That stays. The distinction is what is measured: a template archetype is our code and gates hard, while an individual published page carries content, images and third-party widgets nobody controls at build time, and gets advisory monitoring. Same thresholds, two enforcement levels, deliberately.

N.9 — Accessibility for conversion components

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.

OThe editorial tier

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.

O.1 — Shape

O.2 — What earns a place here

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.

Fits

  • A rebate program opening in this state
  • What a local code change means for homeowners
  • How a regional climate quirk affects sizing
  • A seasonal maintenance window tied to local weather
  • A community event the company took part in

Does not fit

  • "The benefits of packaged air conditioners" with no local angle. That belongs on a service pillar, or nowhere.

O.3 — Consolidation, not accumulation

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.

O.4 — For a small team, this tier is optional It is the one part of this architecture that can be skipped without damaging anything else, and it should be skipped if nobody can sustain it. An abandoned blog with three posts from eighteen months ago is a worse signal than no blog, because a dated page is visible evidence that the lights went out. Ship the tier when there is a real cadence behind it.

PService-area businesses: one location, many towns

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.

Most local trades are not that They are one shop serving thirty or forty towns they have no premises in. Built literally against the original model, such a business emits forty 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.

This section is the variant. It changes the entity model and the city page. It changes nothing about intersections, gates, content floors or evidence.

P.0 — Which model applies

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.

P.1 — Entity model

P.2 — URL and page model

P.3 — What a city page may and may not claim

May

  • That the company serves the town
  • Jobs actually performed there, with photos and dates
  • The technicians who cover it
  • Realistic response expectations
  • The real central phone number

May not

  • An address in that town
  • Hours specific to that town
  • "Our Arlington office"
  • A local phone number that does not ring anywhere real
  • A map pin on a place the business does not occupy
The three-project minimum does more work here than anywhere else in this spec Strip a service-area city page of its projects and nothing true remains that is not also true of every other city page — which is the definition of the doorway page this architecture exists to avoid. Without projects, the honest page is the one you have not published yet.

P.4 — Google Business Profile

P.5 — Migrating an existing flat structure

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.

  1. Freeze the registry and the convention first (section B). Migrating into a convention you have not frozen means doing it twice.
  2. Map, do not guess. Existing service slug 301s to its registry slug; existing /[city]/ 301s to /service-areas/[city]/.
  3. Gate G.5 applies: every redirect source must exist in the previous crawl, one source to one target, caps per PR, human review above the cap.
  4. Do not migrate a page to a page you have not earned. A thin city page with no projects behind it retires to the /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.
  5. Retire rather than rewrite where the old page has no place in the new structure. A 301 into the nearest true parent beats leaving an orphan live because deleting felt risky.

QUser-centred gates: UX, copy and budgets

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.

Q.1 — UX acceptance tests

RuleEnforcement
At 375px, a phone link or booking button is visible within the first 600px of scrollfail staging, warn production for 30 days
At 1280px, at least one real customer review is visible without scrollingfail staging, warn production
A booking form asks for 6 fields or fewer before submissionfail staging
Project photo alt text includes the city or the servicewarn
Why staging fails and production warns, at first A UX rule applied retroactively to a live site breaks deploys on pages nobody has touched. Failing staging stops new work from regressing while the production warning builds the list of what already needs fixing. Promote production to fail once that list is empty, and write down the date you did.

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.

Q.2 — Content linting

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.

The banned list is short on purpose These four phrases are not bad writing in the abstract, they are evidence of absence. A company with a named technician, a dated project and a verified licence does not need to claim it is a trusted partner, because the page already demonstrates it. Reach for the phrase and you are papering over a proof point the architecture was supposed to supply.

Q.3 — Performance and accessibility budgets

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
Reconciliation with section F Section F already hard-fails Lighthouse below 80 and CLS above 0.1 on the six template archetypes. Both stand. The archetype gates are our code and fail hard; these budgets measure published pages carrying content and third-party widgets, and they report. Contrast and focus ring are the exception and fail immediately, because they are template properties — an unreachable control is not a performance regression, it is a page some people cannot use.

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.

Rv5.1 implementation addendum, and image governance

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.

R.0 — What was built before it was specified

Honest accounting. Each of these runs in the All Spark build today and had no home in the specification until now.

RuleWhere it runsBelongs to
isPhysicalBranch conditional on LocationHubtemplate selectionSection P
DOM node budget: warn above 2,500, fail above 4,000CI gateSection Q
Internal anchor budget: warn above 90, fail above 120, scoped to mainCI gateSection Q
data-placeholder attribute disciplinetemplate render + CIthis section
Offers validUntil, 7-day minimum, auto-archivespecials moduleSection N
Orphan detection, fewer than 3 inbound content linksCI gateSection F
Face detection with a review queueimage pipelinethis section
Disclaimer partial on symptom pagestemplate partialthis section
Phone numbers in E.164template + CISection Q
Service.provider bound to the site's LocalBusinessschema emitterSection C
A specification that lags its build is worse than either alone Anyone reconciling the two has to guess which is authoritative. The rule going forward: a gate does not ship before the sentence describing it.

R.1 — Anchor budget scope

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.

R.2 — Navigation must not point at empty rooms

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.

R.3 — Disclaimer on symptom pages

This guidance is for informational purposes only and does not replace an on-site inspection by a licensed electrician. Codes may have changed since publication.

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.

R.4 — Focus visibility and safe areas

R.5 — Image governance

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.

Build the queue before requesting the first photograph from a client. A warning with no interface behind it is a warning nobody receives.

R.6 — Schema binding and formats

Sign-off checklist

Sprint zero opens when all five are true:

Per-city launch gate

Deployment and maintenance procedures that sit outside this spec live in the operational runbook.