08 — SEO / SERP / GEO signal reference
The search-signal families (S1–S6) as centrally-pulled signals under the signal contract. This is the domain reference; the contract defines shapes/flags/latency rules, doc 03 defines how any of it becomes a causal claim, and the ledger (doc 00) is where value is booked — positions and citations are inputs, never outcomes.
The asset's own report (doc 02) covers the on-site half of each asset's health. This doc specifies the demand-side half: organic search and AI answer engines, asset-agnostic from day one, same as the signal contract.
Two facts determine the design:
- A citation in an AI Overview has replaced the featured snippet ("position zero") as the top placement. With an AI Overview present, organic #1 loses ~58% of clicks; cited pages get ~2.3× the CTR of uncited ones. So we track "are we cited?" as a first-class dimension next to rank.
- Brand mentions predict AI visibility ~3× better than backlinks do (Ahrefs, 75k brands). So brand-demand and mention signals are KPIs, not vanity metrics.
Signals live in NoticeOS, not in asset workers
Unlike the asset's report (each asset reports on itself), SEO signals are pulled centrally by NoticeOS crons: the external APIs (GSC, GA4, DataForSEO) need portfolio-level credentials and a shared spend budget, and none of it requires code inside the asset. Assets contain no search-signal code; the Tower joins the asset's report × search signals on the asset id. One table family in the store, one row per (asset, date, signal).
Signal families
S1 — Domain-wide ranking intelligence
The weekly central DataForSEO collection reads each launched domain from Postgres and archives its top 200 current Google rankings in the site's saved market (United States · English by default). Each row keeps the query, ranking URL/type/position, estimated demand, CPC, difficulty, intent, SERP features, estimated visits/cost, rank-change fields, and any AIO-reference result. This broad inventory is used for:
- current top-3/top-10/top-20 footprint;
- ranked near-win recommendations that exclude navigational and home-page queries before prioritizing volume, rank, CPC, and difficulty;
- locally retained week-over-week rank gains/losses;
- AIO-reference counts without relabeling ordinary organic rows as AI.
The 200-row ceiling is explicit and means “highest-volume provider rows,” not complete keyword coverage.
The property-detail read model exposes a bounded current query baseline from this archive even before a second weekly snapshot exists: modelled monthly searches, organic position, keyword difficulty, landing page, SERP AI Overview presence, and query-level ai_overview_reference citation position. It joins those facts by normalized query to GSC/BWT movement for presentation, but never labels search volume as impressions. Rank movement is local evidence only when the same query/page pair exists in two retained snapshots.
S1b — Tracked-query SERP panel
The weekly Monday DataForSEO collection's serp-panel family makes one serp/google/organic/live/advanced call per (tracked term, device) with load_async_ai_overview set, archived to the same immutable R2 + append-only manifest boundary as the other families (one manifest row per property per run, the per-query responses as its pages). The panel itself is config/serp-panel.json (+ README sibling); the flattened family is dataforseo-serp-panel.csv. Full collector behavior: workers/ingest/README §The tracked SERP panel. The rules that differ from "one family, one call":
- Every tracked term is read on both devices. Most food/health search is on a phone, and phone result pages show blocks that take clicks on different queries. One call per (query, device), still one family, one archive and one manifest row per property per run. The device is stored in the archived request body — a documented provider field — and appears in
dataforseo-serp-panel.csvas adevicecolumn. The executive snapshot's AI Overview evidence stays desktop. - Depth is 20, not the whole result page: a property below it records no rank, which the CSV and the Tower both show as "not inside the tracked depth" rather than "not ranking".
- A missing AI Overview and an unreadable one are different facts. Where the asynchronous overview does not load,
aio_presentis unknown (empty), neverfalse; only a cleanly parsed page with no overview recordsfalse. The decision rules treat unknown exactly like an untracked query. secondPos,features[]andsitelinksUsare flattened assecond_rank/second_url,serp_featuresandsitelinks_us— no new provider spend, because the response was already paid for.sitelinks_usis three-state like theaio_*pair: empty means the property had no result inside the tracked depth, so there was no result of ours that could show sitelinks — read asfalse, it would raise the sitelink-loss alert every week a ranking dropped below depth 20. What deliberately stays in the archive rather than becoming a column: overview text and full citation lists, result titles and snippets, the question text insidepeople_also_ask/related_searches, and paid blocks — scripts/README §dataforseo-serp-panel.csv gives the reason for each.- The alert rules below are not wired: the panel is an input to the query decisions and the
search-striking-distancegate, not to the Needs Attention section. PromotingbestPosdrift and lost citations to lifecycle flags requires repeated snapshots, the same requirement the broad S1 comparison has.
Effect on query decisions. The query decisions (doc 10) apply two rules:
- a decision that would have recommended title or snippet changes on a query with an AI Overview that does not cite us is demoted from act to investigate, because the change would add churn without gaining clicks. This is the impression-harvest gate (snippet work gains clicks only where users still click the result), applied automatically;
- a query the overview does cite is classified as protect regardless of its position trend, with a next step that recommends keeping the cited content intact.
Every collection is reviewed by a person. When a panel collection arrives, the runner job in scripts/os-up.mjs files a panel-review task into the property's own task project — due seven days later, one per property per panel day, based on the GET /api/serp-panel-landings read. The panel data is of no use until someone triages it. The panel day sets the date and the review covers the week's whole collection — the broad inventory, the link and LLM families and the property's own GSC/GA4/Bing exports arrive on the same schedule. What closing that task requires — reviewing the panel, then the inventory of the week's other families — is written into the task by scripts/runner/panel-review.mjs; the triage state is stored in the task snapshot as panelReview so the Tower can show a property whose review is overdue.
Cost: each tracked query is one metered call per device (~$0.004 each); a panel of 25 terms is roughly $0.20 a Monday, under $1/month against the $25 cap. It reserves the same per-report amount as every other family before calling and fails closed under the same portfolio cap — there is no panel-specific exception in the gate, which is why the second device halves the query ceiling rather than widening the reserve.
A hand-picked panel of 10–31 head terms and strategic bets provides the mobile/desktop monitoring of neighbouring results and exact citations:
{ "asset": "example.com", "date": "2026-07-06", "query": "example calculator",
"device": "mobile", "bestPos": 3, "bestUrl": "/", "secondPos": 4,
"top3": ["example.org", "example.gov", "example.com"],
"aio": { "present": true, "citesUs": false, "cites": ["example.org", "..."] },
"features": ["people_also_ask"], "sitelinksUs": false }A site's panel seed is its row in config/serp-panel.json: its brand and navigational terms, the head terms it gets impressions for, and the bets its current plan is testing — for example example · example login · example calculator · best free calculators. Its queries are that site's own; an installation keeps the history of each seed in its own notes.
Add a query when Search Console shows four-figure impressions at position 1–5 with zero clicks and several URLs sharing byte-identical impression counts on every date — the pattern produced by one SERP block crediting the property, not by pages competing. Search Console data cannot show whether that block is an AI Overview; the panel records exactly that. A provider error on a query leaves every column unknown, not false: the call is billed, returns no data, and is recorded as such; the next scheduled run retries it.
S1c — Discovery (net-new demand and the competitive set)
Every other search family in this doc covers only existing terms: S1 reads what a property already ranks for, S1b reads terms an operator already chose, S2 reads queries that already received an impression. None of them can propose a term the portfolio has never targeted, so Decide could rank existing opportunities but never find a new one; that is the difference between optimizing a page and choosing what to build. Doc 13's opportunity scouting has no input without these families.
Two families, both on a 28-day cadence rather than weekly:
keyword-ideas(dataforseo_labs/google/keyword_ideas/live) — net-new demand, top 100 by search volume. Seeds come from the property's ownconfig/serp-panel.jsonhead terms, not from a new config file and not from the storedranked-keywordsarchive: the panel is the operator's own statement of which terms matter, it is already loaded by the collector, and reading the archive instead would put an R2 fetch and a gzip parse inside a paid collection. A property with no panel has no declared head terms and therefore gets no ideas, rather than ideas generated from a guess.serp-competitors(dataforseo_labs/google/competitors_domain/live) — top 20 ordered byintersections, i.e. how many of our keywords a domain also ranks for. A bigger domain that shares none of our keywords is not a competitor. Without it, competitors are found only incidentally from thetop3of whichever panel queries happen to be tracked, so the competitor list would depend on the panel rather than on the market.
Why the monthly cadence matters. Net-new demand and the competitive set do not change week to week; collecting them weekly would pay for four copies of the same result. ~$0.02 a call means ~$0.20/month across a small portfolio versus ~$0.87 at weekly — small on its own, but keeping each family's cost low is what keeps total spend within a $25 cap.
Two cadences, two separate checks. The read models check whether a report_date is complete against DATAFORSEO_BASE_REPORTS. A 28-day family is expected to be absent from three report_dates in four, so listing it there would mark three weeks in four incomplete. "Was this week complete?" and "which families may be requested for this property?" are separate questions: dataForSeoReportsFor handles the first, and the collector's dataForSeoFamiliesFor (reading the REPORTS registry and its appliesTo) handles the second.
Deliberately not built. domain_intersection (the content gap — they rank, we don't) is the most directly actionable list of the three and is planned as later work rather than part of this: it needs a chosen competitor per property, which serp-competitors has to establish first, and guessing that competitor would pay for the wrong gap. Local-pack and Google Business Profile families are not collected — no portfolio asset is a local business. Historical SERP families are not collected because the archive builds its own history every Monday and paying for backfill would duplicate it.
S2 — GSC daily pull (free, richest)
Search Analytics API nightly per asset: clicks/impressions/position by query and by page. The analysis archive also retains page×country, page×device, page-level search appearances, image-search page×query, and Discover pages. Discover is collected in probe mode: while every completed run for a property has returned nothing, only the newest revision date is requested, so a family the property does not participate in costs one request a day instead of four. The full revision window resumes as soon as a run returns a row. Store the raw data; derive the views that decisions are based on:
- Position-bucket distribution over time — counts + impressions in pos 1–3 / 4–10 / 11–20 / 21+. A property with a few dozen queries in the top 3 and hundreds at 4–10 has most of its queries just below the top 3; for such a property, this chart tracks the strategy directly.
- Striking-distance list, auto-refreshed — pos 4–12 × impressions ≥100, the standing to-do list for content work.
- Brand vs generic split (per-asset regex over the brand's spellings) — branded-impressions trend is the leading indicator for sitelinks, AI visibility, and algorithm-update resilience.
- CTR-vs-position curve vs benchmarks (no-AIO ~3.3% / AIO-cited ~2.1% / AIO-uncited ~0.9%) — flags title/snippet problems: a page can stay under 1% CTR on five-figure impressions for a quarter before anyone notices.
- GSC's Search Generative AI report (AIO/AI-Mode impressions, impressions-only) — ingest when the API exposes it. The documented Search Analytics API has no generative-report type, so ordinary Web Search data must not be relabeled as AIO-specific.
S3 — AI/GEO visibility
- GA4 "AI Assistant" channel: sessions/wk from chatgpt.com, perplexity.ai, claude.ai, etc. The daily
landing-page-acquisitionarchive keeps the landing URL beside source, channel, engagement, key events, and revenue. Treat it as a lower bound — not every assistant supplies a usable referrer. - DataForSEO LLM-mentions weekly target metrics for Google and ChatGPT: reported mentions, represented AI search volume, and leading source domains. The endpoint accepts one task, so properties/platforms are separate calls rather than one combined portfolio batch. These are provider-dataset coverage metrics, not unique people or attributable sessions.
- S1b's
aio.citesUsis exact, per-query, live-SERP citation state for the terms on a property's panel. It covers the panel and nothing else — a query nobody tracks is unknown, not uncited.
S4 — Authority & links
DataForSEO backlinks weekly: current domain rank/backlinks/referring domains plus the provider's weekly-grouped 90-day new/lost series. The executive cards show net referring-domain direction.
Two bounded detail families,
backlinks-referring-domainsandbacklinks-anchors, run on the same weekly cadence and the same immutable R2 + append-only manifest boundary. A summary count and a weekly delta cannot identify which domain was lost; these families exist so "we lost 12 referring domains this week" can become a list of reclamation targets.Both are capped at the top 100 rows, ordered so that a truncated read still contains the rows that matter: referring domains by
rankdescending (the links worth having, not whichever scraper farm links most times), anchors byreferring_domainsdescending (an anchor profile is read as a distribution, and the top rows show most of it). The cap is chosen for cost first and coverage second — the Backlinks API bills $0.024/request + $0.000036/row, so 100 rows is ~$0.0276 a call against the $0.25 per-family reserve.The anchor read is also an AI-visibility input, not only a risk one: this doc's second premise is that brand mentions predict AI visibility ~3x better than backlinks, and brand-vs-exact-match anchor share is the closest measure of that in the link families.
Reclamation pipeline as a first-class table (target page, dead URL it replaces, contact, status, won-link URL) — wins are events on the timeline. This tactic applies wherever a dead .gov/.edu resource has a successor.
Ahrefs free DR checkpoint quarterly.
S5 — Tech/GEO hygiene (regression guards)
The nightly half that only fetches pages — static-HTML depth, robots AI access, sitemap and page structure — runs at 04:00 UTC in workers/ingest/src/hygiene.ts, storing one row per (asset, check, day) in hygiene_checks. Six plain HTTPS requests per property, no provider and no quota. Full behavior, reporting rules, and the parser's documented limits: workers/ingest/README §Hygiene guards.
Page-level directives:
<meta name="robots">andX-Robots-Tagare read on three sampled real pages per property per night, under rulehygiene-page-directives. The sample is drawn from the sitemap the sweep already fetched — a separate list of key routes would be a second list to keep up to date, and the property already publishes one. The sample is stable (hash-ordered), so the same pages are checked night after night; it is three pages, and the recorded reading states that rather than presenting the property as clean.An unreachable home page is its own rule,
hygiene-home-unreachable, on the same fetch as the depth check.That fetch is also each site's uptime check: the home-page check alone runs every hour as well (
runUptimeChecks), andhygiene-home-unreachablefiles aterror—erroris reserved for failures that stop revenue, and a site that does not respond is one. Robots, sitemap and the page sample stay nightly.A failure is confirmed before it raises an alert: a home page that fails is requested once more 45 seconds later in the same run, in both the hourly check and the nightly sweep. Only two failures in a row file
hygiene-home-unreachable; a page that responds to the retry is recorded as up, with the failed attempt noted on its reading.Page structure is a fourth check,
page-structure, making four more checks on the pages the directive rule already fetched: is there a<title>, a meta description, exactly one<h1>, and does the canonical point at this page or point away from it to another. It costs no new requests — a served page is one document, and fetching it twice would double the nightly request count with no new information. Only changes raise a flag, so a page that has always shipped without a description is treated as a standing editorial decision rather than a new incident.The taxonomy follows badseo.dev — a site where every page breaks exactly one rule — and so does its test approach:
STRUCTURAL_FIXTUREShas one page per fault and a coverage test that fails if the parser detects a fault that no fixture page demonstrates. The other twenty-odd checks of a full audit are not included: this list records only guards with a decision attached — the snippet standard (a title and description written for the query, not the page) for the snippet pair, and "a page canonicalized elsewhere cannot rank" for the most serious of the four. Title and description length are deliberately excluded: Google truncates by pixel width and rewrites titles at will, so "your title is 61 characters" is a matter of taste with no decision behind it, and an operator who receives flags about matters of taste starts ignoring the check.A structural fault is not automatically an act. A missing title on a page with impressions is worth a morning's work; the same fault on a page nobody reaches is worth nothing. That triage needs traffic evidence, which is in the Tower's page decisions and not in this check.
The depth, robots and sitemap guards file at warn, not the
errorthe alert-rules line below specifies.erroris reserved for failures that stop revenue, and a hand-written word count that has never fired in production has not yet shown it is reliable enough for that level. Promote toerrorafter the first true positive — that is the trigger, and it is a one-line change.robots.txtis parsed at site level only — a blanketDisallow: /per user-agent group. A path-scopedDisallowis still treated as allowed, and longest-match evaluation is only worth building once a property actually ships path-scoped AI rules.Security headers are read from the same home-page response the depth check counts:
Strict-Transport-Security,Content-Security-Policy,X-Content-Type-Options,X-Frame-Options,Referrer-PolicyandPermissions-Policy, each recorded as present with its value (cut to 300 characters) or absent, in onesecurity-headersreading a day. Only a 200 is read.hygiene-security-headers(warn) fires when a header the previous read had is missing, never because a site has not set one, and resolves when it is sent again. The check reads headers and changes none.CrUX page speed is its own daily job at 04:15 UTC, keyed by the Chrome UX Report connection: the 75th percentile of LCP, INP and CLS for phones and desktops, one
crux-vitalsreading per site per day (doc 11). No alert rule reads it yet.TLS certificate expiry is not checked: the Worker's
fetchdoes not expose the peer certificate.Static-HTML depth check: words-of-visible-content in the served HTML. AI crawlers don't render JS; a home page can serve under a hundred words for months with nobody noticing. Nightly fetch + count, alert on -50% vs baseline.
robots/AI-crawler diff: alert if robots.txt or meta-robots changes drop an AI search bot (OAI-SearchBot, ClaudeBot, PerplexityBot) or add nosnippet. Cloudflare default-blocks mixed-use crawlers on ad-serving pages for new/free customers — re-verify each asset when ads ship.
Sitemap reachability + URL count: resolve the sitemap from
robots.txt(else/sitemap.xml), prove it parses, count<loc>entries following one level of<sitemapindex>; alert on unreachable, unparseable, or a -50% count collapse off a base of ≥50 URLs.CrUX API daily: the INP/LCP/CLS 75th percentiles per asset, rated good, needs improvement or poor on the site's Data sources tab (tiebreaker-level priority, but free to monitor).
Scope note: an asset may already prove link-graph/sitemap/canonical integrity in its own CI at build time. S5 checks the served layer (what a crawler actually receives); don't duplicate build-time proofs — require them in the asset's CI bar (
AGENTS.md) instead.
S6 — Monetization platform-state signals (doc 09)
Scope note: S6 is the platform-health guard family (ads.txt reachability, consent regressions, application states) — distinct from the ledger's revenue collection in doc 02, which books actual dollars. The portfolio monetizes via ads + affiliate; platform state is collected as a signal family, not kept in a spreadsheet that someone has to remember to check:
- ads.txt reachability + content hash per ad-serving asset; a 404 or an unexpected diff → error (a failure that stops revenue).
- Consent-surface regression: the consent-mode block (which reads the stored choice + GPC and sets consent signals) must precede analytics/ad loaders in served HTML, and a stored reject/GPC must skip the loaders; privacy-policy + "Do Not Sell" routes must return 200. The DEFAULT is a per-asset setting, not the invariant — a site may run the US opt-out model (default granted, denied on reject/GPC), and default-deny halves GA4 reporting without any visible error. A regression here is a compliance incident → error.
- Ad-platform state log: AdSense/Ad-Manager application + policy-center states are manual-entry events on the asset timeline (e.g. a re-review window, ~2–4 weeks post-remediation) — the Tower shows "days since state change," and the advisory layer does not propose risky content during a review.
- Affiliate-click trend is part of the asset's report (doc 02); the Tower charts it next to RPM once ads are live.
- Storage-lifecycle check where user content is hosted (an asset's user-upload bucket): object count should level off under the 30-day expiry; unbounded growth means the lifecycle rule was removed without anyone noticing → warn.
Alert rules (tunable per asset; flag shape = severity × kind per doc 02)
- S1b:
bestPosworsens ≥3 vs 4-week median → warn/anomaly · AIO citation lost → warn/anomaly · new domain takes #1 on a panel query → info/anomaly · sitelinks appear/disappear on brand query → info/anomaly. None of these are wired. The collector and the flattened family exist; the panel is an input to query decisions, and every rule here compares against retained history. As with the S1 line below, these rules need repeated snapshots first. The columns all four rules read exist (second_rank,serp_features,sitelinks_us); what is missing is history, not fields — and the sitelink rule depends onsitelinks_us's empty state: it must compare only weeks that have an observation and skip the weeks the property had no result inside the tracked depth. - The broad S1 weekly comparison emits executive warnings/discoveries, not lifecycle flags; repeated provider snapshots and real GSC impact are required before promoting those rules into Needs Attention.
- S2: top-10 page's clicks last7d < 60% of trailing-28d avg → warn/anomaly (min-count gated per doc 02) · branded impressions −30% WoW → warn/anomaly · new query enters striking distance with >500 impressions → info/opportunity.
- S4: DR-60+ referring domain lost → info/anomaly · reclamation win → info/milestone (wins are recorded for morale; their severity stays info).
- S5: static-depth −50% on a key route → error/anomaly (deploy regression). Files at
warn/anomaly today, withhygiene-robots-aiandhygiene-sitemapalongside it at the same severity — see S5 for why, and for the condition that promotes it.
Executive card rules — inventory
Distinct from the lifecycle flags above: these are deterministic rules over the archived report families that emit cards, not flags, and never write to the Needs Attention list. The full table with the exact family each rule reads is in scripts/README.md; the page sorts warning → recommendation → discovery → insight and shows eight. Each card includes its rule id as evidence (rule: <id>). Cards after the eighth are listed in suppressedItems rather than dropped. The cap limits only what is displayed in full: a finding past the cap is not shown as a card, but it is always listed. The Tower lists them too: below the findings, a small grey "N more below the top 8" expander shows their titles. Each entry has only the kind dot and title, not a rendered finding: no evidence and no mark/dismiss controls, because the page cannot offer an action for a card it is not showing. The cap stays at 8 until evidence shows eight is the wrong number of cards to read in one sitting.
Rules generalized from manual-analysis history (doc 13 category 6), with the threshold and the finding each encodes:
measurement-integrity(warning) — ≥10% Unassigned/(not set)sessions, or an event that ran ≥100/day dropping ≥90% WoW. A property with over 10% Unassigned sessions must qualify every number in its largest analysis. This card qualifies every other card on the page rather than adding a finding, and sorts first for that reason. The OS reports the fault only — the measurement channel is operator-only by invariant (doc 01).concentration-risk(warning) — organic search ≥85% of ≥100 sessions, the single-channel threshold doc 00 lists as the #1 devaluation factor.query-cannibalization(recommendation) — ≥2 of the property's own pages each holding ≥20% of one query's impressions, above the striking-distance volume minimum (25/reported date, never below 100). A homepage can outrank its own interior pages for months before anyone notices.query-language-drift(recommendation) — a country holding ≥5% of impressions (≥1,000) clicking at ≤half the CTR the property gets excluding that country. The problem it detects is meta copy written in the site's language, not the searcher's; a one-character fix can be worth four figures of impressions a month. Both thresholds follow from that pattern: a country large enough to matter also pulls the property-wide CTR toward its own CTR, so comparing against the overall number would hide a locale gap, because the country is part of the number it is compared with.device-ctr-gap(recommendation) — one device's CTR ≤ half the other's, both ≥1,000 impressions, in either direction. Not position-adjusted; a device-only ranking gap produces the same pattern, so the card recommends comparing positions first. No archived family contains query × device, so the grounding exclusion (below) cannot be subtracted where it was observed; it is attributed entirely to the weaker device as a worst case, which can only make the rule fire less often.prune-candidates(discovery) — pages under 5 impressions across a window of ≥14 reported dates, on properties with ≥100 reported pages. A full prune rule is "<5 impressions in 90d AND no internal links"; only the impressions half exists in this archive and the card shows that rather than implying the link half was checked. A page missing from the export entirely is unknown, not zero, and is never counted.page-movers(insight) — page-grain clicks, ≥10 absolute change across two complete seven-date windows, mirroring the existing query movers. Cards only; it deliberately adds no payload block, so the Tower needs no change.llm-grounding-traffic(discovery) — quoted-literal queries are machine grounding: queries like"1 medium banana" "3/4 cup" food groupwith zero clicks between them are automated retrieval checks, not people, and can be close to a tenth of all captured page/query impressions. They are excluded from the CTR and position rules and from the query movers the query decisions read, Google and Bing alike — and the exclusion is always shown: each affected card, and each movers list, shows the excluded impressions and share, including when they are zero. The card is a GEO signal from the free GSC data with page-level detail the paid mention collection does not have, and it supports S3 with independent data. The classifier covers the quoted pattern only — unquoted queries that look like homework questions stay in the series, so the number is a lower bound for the behavior rather than a full measure of it.search-striking-distancewithholds a term where the panel observed an AI Overview that does not cite this property. Several of the property's URLs at position 1.0–1.3 with byte-identical impression counts and no clicks is one block — an AI Overview or a sitelink group — crediting them at the block's own position, and the consolidation a cannibalization card would recommend there would cause harm. The harvest rule favours low click-through, so without the gate it would recommend exactly those SERPs first; the card lists the terms it withheld. Only an observed non-citation withholds a term; an untracked term, or one whose overview did not load, is still listed and marked unknown on the card.bing-search-opportunityreads the query's own periods, labels a decline as a decline rather than as an opportunity, labels it intent mismatch when a long window at a visible position gets no clicks, caps confidence at medium for either, and never joins across a locale subtree.- The backlink momentum card shows the provider's observed inventory so a reader can judge coverage before direction: a direction measured over a dozen referring domains on a DR-30 domain is a real change measured on too small a sample. A second link data source is not wired (S4 has one source); that is the current state.
distant-demand-clusterexists because the near-win range (position 4–20) suits near-wins but is too narrow as the only range checked for a property: it misses five-figure monthly searches at positions 37–49 that map to a page the site already has. Doc 13's opportunity scouting exists so that a scope filter does not hide opportunities like this.value-event-not-key-event— a value event carryingkeyEvents = 0while a lesser event is marked key sets every conversion-based read the OS makes to zero without any error, including theai-referral-floorcard's own "Key events" evidence row. Value events are declared per property inconfig/value-events.json(+ README sibling). Both sides of the comparison are operator-only — the measurement channel isforbidden-class — so the rule reports the mismatch and edits neither.
Several rules fire only after enough history has accumulated — the week-over-week and prune rules need 14 reported dates — or once a property actually crosses a threshold.
The "did it help?" join
Annotate each asset's deploy dates (git tags / CI webhook) onto every S1/S2 time series. The advisory layer's core question — did the last change move the signal? — can be answered only if changes and signals share a timeline. A batch of search-facing changes shipped on one day is a natural experiment: the panel + GSC series should show whether it moved within 2–6 weeks.
Cost envelope
A five-family weekly run costs roughly $0.26–$0.29 per property; weekly cadence projects to a few dollars a month for a small portfolio, below the $25/month data cap. Exact cost is indexed per report; the scheduler reserves $0.25 before each call and fails closed rather than crossing the cap. S2/S5/GA4 remain free.
The S1b panel adds ~$0.004 per (tracked query, device) and nothing at all for the properties without one. A panel at a 40-query ceiling would cost $0.32 at two devices — more than the $0.25 the gate reserves for one family, i.e. a family spending more than the reserve its own gate took, without any error. So the ceiling is derived: 31 terms, which is exactly what $0.25 pays for at two devices. Adding a dimension halves it again; raising it requires a decision to change the reserve, not only a new number.
Not built
- S5: an alert rule on the CrUX readings; TLS certificate expiry.
- S1b: the alert rules above (they need history, not new fields); the Tower's device-aware panel board and the executive snapshot's panel block.
- S4: bounded backlink-detail reclamation candidates and a second link data source; the operator-exported Bing AI Performance collection, when its real schema is available.