@canonry/canonry 5.22.0 → 5.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +2 -1
  2. package/assets/agent-workspace/skills/aero/SKILL.md +37 -3
  3. package/assets/agent-workspace/skills/aero/references/portfolio-analysis.md +26 -12
  4. package/assets/agent-workspace/skills/aero/references/reporting.md +1 -0
  5. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +100 -5
  6. package/assets/agent-workspace/skills/canonry/references/google-business-profile.md +30 -6
  7. package/assets/agent-workspace/skills/canonry/references/indexing.md +1 -0
  8. package/assets/agent-workspace/skills/canonry/references/server-side-traffic.md +30 -0
  9. package/assets/assets/{AuditHistoryPanel-teTDC-4-.js → AuditHistoryPanel-CWd9UN6L.js} +1 -1
  10. package/assets/assets/{BacklinksPage-Bqm7bido.js → BacklinksPage-B2gPTzyE.js} +1 -1
  11. package/assets/assets/ChartPrimitives-B5okB5g-.js +1 -0
  12. package/assets/assets/{HistoryPage-IcqwPBmt.js → HistoryPage-xtkG8SZN.js} +1 -1
  13. package/assets/assets/{MeasurementPropertyPage-BLpptT3_.js → MeasurementPropertyPage-DxIMU3_T.js} +1 -1
  14. package/assets/assets/{ProjectPage-nn2Y9H-m.js → ProjectPage-CMAFZq_s.js} +5 -5
  15. package/assets/assets/{RunRow-S_SCHr0o.js → RunRow-DnSo7Nle.js} +1 -1
  16. package/assets/assets/{RunsPage-DFiif626.js → RunsPage-FAEuo4Ra.js} +1 -1
  17. package/assets/assets/{SettingsPage-CitZ6FAS.js → SettingsPage-C4OZ1HB9.js} +1 -1
  18. package/assets/assets/{SiteHealthSection-C2dsyNGY.js → SiteHealthSection-BzMxlz8-.js} +3 -3
  19. package/assets/assets/{TrafficPage-lY8na5Pg.js → TrafficPage-D03S6xF6.js} +1 -1
  20. package/assets/assets/{TrafficSourceDetailPage-BTykoVGT.js → TrafficSourceDetailPage-DBu6oFFK.js} +1 -1
  21. package/assets/assets/{extract-error-message-r0Gpc7mv.js → extract-error-message-BuH2CCaJ.js} +1 -1
  22. package/assets/assets/index-Ba-Zs45G.js +86 -0
  23. package/assets/assets/index-DfhmHULl.css +1 -0
  24. package/assets/assets/{react-sigma_core.esm.min-DDrDxbO1.js → react-sigma_core.esm.min-CxwZsgxR.js} +1 -1
  25. package/assets/assets/{v2-overview-adapter-OsQiglXU.js → v2-overview-adapter-DTCKxzZT.js} +1 -1
  26. package/assets/index.html +2 -2
  27. package/dist/{chunk-P5VXVFQW.js → chunk-7RKCDOWO.js} +6773 -5254
  28. package/dist/{chunk-YTSXQUJ3.js → chunk-DX2JUC5H.js} +3 -3
  29. package/dist/{chunk-XFHXKLNT.js → chunk-EP4STEPI.js} +3542 -1165
  30. package/dist/{chunk-GLLY2GEV.js → chunk-FNGITGDF.js} +172 -7
  31. package/dist/{chunk-S5OSY3PI.js → chunk-KVQHG3WB.js} +6693 -5891
  32. package/dist/{chunk-DDL2XSNM.js → chunk-MPICT6V7.js} +128 -35
  33. package/dist/{chunk-3LCR6WU6.js → chunk-ONTEFAA7.js} +2 -2
  34. package/dist/cli.js +436 -44
  35. package/dist/{demo-server-HULNEORN.js → demo-server-HWWIMGD6.js} +4 -4
  36. package/dist/index.d.ts +9 -1
  37. package/dist/index.js +6 -6
  38. package/dist/{intelligence-service-CXNBKRHV.js → intelligence-service-6LDMVI66.js} +2 -2
  39. package/dist/mcp.js +4 -4
  40. package/package.json +14 -13
  41. package/assets/assets/ChartPrimitives-BQC2VH0O.js +0 -1
  42. package/assets/assets/index-DwlO8KfE.js +0 -86
  43. package/assets/assets/index-EJC8PSbh.css +0 -1
package/README.md CHANGED
@@ -126,6 +126,7 @@ For an existing project, configure a provider with your own key.
126
126
  |---|---|---|
127
127
  | Gemini | [Google AI Studio](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` |
128
128
  | OpenAI | [OpenAI Platform](https://platform.openai.com/api-keys) | `OPENAI_API_KEY` |
129
+ | Meta (Muse) | [Meta Model API](https://dev.meta.ai/) | `MUSE_API_KEY` |
129
130
  | Claude | [Anthropic Console](https://console.anthropic.com/settings/keys) | `ANTHROPIC_API_KEY` |
130
131
  | Perplexity | [Perplexity settings](https://www.perplexity.ai/settings/api) | `PERPLEXITY_API_KEY` |
131
132
  | Local model | Any OpenAI-compatible endpoint | `LOCAL_BASE_URL` |
@@ -203,7 +204,7 @@ Connect the sources you use. Each integration adds evidence or tools to the same
203
204
 
204
205
  | Area | Supported integrations |
205
206
  |---|---|
206
- | **AI answers** | ChatGPT · Claude · Gemini · Perplexity · OpenAI-compatible local models |
207
+ | **AI answers** | ChatGPT · Claude · Gemini · Meta (Muse) · Perplexity · OpenAI-compatible local models |
207
208
  | **Search and local** | [Google Search Console](docs/google-search-console-setup.md) · [Bing Webmaster Tools](docs/bing-webmaster-setup.md) · [Google Business Profile](skills/canonry/references/google-business-profile.md) |
208
209
  | **Analytics** | [Google Analytics 4](docs/google-analytics-setup.md) |
209
210
  | **Conversion measurement** | [Google Ads + Google Tag Manager](docs/google-marketing.md): read-only snapshots and declared conversion contracts |
@@ -41,6 +41,31 @@ covering that work; an existing explicit authorization remains valid.
41
41
  provider/model, location, and query class. Read
42
42
  `references/portfolio-analysis.md` before ranking Properties or comparing
43
43
  Advanced results.
44
+ - **Advanced routing.** Answer each question from its read:
45
+ - Is the sweep complete, is anything unreliable:
46
+ `canonry_measurement_data_quality` (quote completeness `expected`,
47
+ `executed`, `missing`, plus `unattributedByClass` and `latestFill`), then
48
+ `canonry_run_completeness` with its `run.displayedRunId` for missing
49
+ answers per engine. A Healthy run status and `canonry_doctor` are not
50
+ completeness checks.
51
+ - Which names answers give instead across the portfolio:
52
+ `canonry_competitor_landscape` with `queryClass` and `runId: latest`.
53
+ Per-Property named-instead lists are samples of weak Properties.
54
+ - What changed: `canonry_measurement_changes` once per class, with its
55
+ `distribution` and `withinNoise`.
56
+ - Which metros have the biggest gaps: the portfolio summary's `markets`
57
+ (every metro) and `tiedAtWeakest.byMetro`.
58
+ - **Noise.** Between two sweeps, a Property that moved 2 answers or fewer
59
+ (`withinNoise: true`) is within noise. Never call it a gain, loss, trend,
60
+ or regression.
61
+ - **Partial results.** A result with `truncated: true`, a total
62
+ (`totalProperties`, `total`, `questionTotal`) above its rows, a
63
+ `nextCursor`, a `__partialLists` field (it comes first and names each list
64
+ the tool cut, as shown of total), or a `__truncation` note is partial. Say
65
+ how many of how many you saw, and never call those rows the biggest, all,
66
+ or the full picture.
67
+ Rows tied at the weakest rate are listed by name, not rank: give
68
+ `tiedAtWeakest.count` and `.byMetro` and call the rows examples.
44
69
  - **Site Health:** read `references/site-health.md` before diagnosing scores,
45
70
  crawl coverage, internal links, or page findings. Technical readiness is a
46
71
  separate signal from measured mentions and citations.
@@ -73,7 +98,11 @@ Before you quote GA4 data, make sure that `cnry ga status` has a recent
73
98
  `lastSyncedAt`. If it is stale, get approval before you run `cnry ga sync`.
74
99
 
75
100
  For Cloud Run, WordPress, Vercel, or Cloudflare, use `cnry traffic status` and
76
- `cnry traffic events` for crawler and AI-referral evidence. Read the Cloudflare
101
+ `cnry traffic events` for crawler and AI-referral evidence. Before you quote a
102
+ server-side AI referral total, run `cnry traffic referral-assessment` for the
103
+ same dates (`canonry_traffic_referral_assessment` over MCP) and review its
104
+ candidate bursts. Quote the unchanged headline beside the separate adjusted
105
+ estimate; a candidate burst is not confirmed automation. Read the Cloudflare
77
106
  `deliveryMode` before you recommend an action. Direct push does not use
78
107
  `traffic sync`. Queue pull freshness requires an enabled `traffic-sync`
79
108
  schedule. Run the `traffic.source.*` doctor checks. Inspect
@@ -94,8 +123,8 @@ Mention is the primary gauge (see "Two signals, not one" above); citation is the
94
123
  1. **Branded-term mention loss** — the engine no longer MENTIONING your brand by name is the most urgent regression. Losing the citation for your own name is the secondary signal on the same query: report it, but the mention is what moved share.
95
124
  2. **Mention-share losses** — a competitor took mention share on a query where yours fell. Rank by share swing first, then by any lost citation on the same query.
96
125
  3. **Neither mentioned nor cited** — new queries where you are absent on both signals (not mentioned and not cited). Mention gap leads; the missing citation is the trailing clause.
97
- 4. **Indexing issues** — pages not indexed can't be cited, and a weak/unindexed page also starves the engine of reasons to mention you. Keep this on the list; it feeds both signals.
98
- 5. **Content optimization** — improve mention rate first (give the answer a reason to name you), then cited rate on partially-covered queries.
126
+ 4. **Indexing issues**, only when indexing or Site Health evidence read in this turn shows them. Pages not indexed can't be cited, and a weak/unindexed page also starves the engine of reasons to mention you; it feeds both signals.
127
+ 5. **Content optimization**, only when page or answer evidence read in this turn points to it. Improve mention rate first (give the answer a reason to name you), then cited rate on partially-covered queries.
99
128
 
100
129
  ### What NOT to Do
101
130
  - Don't promise fixes will appear in the next sweep (AEO changes take weeks/months)
@@ -128,6 +157,11 @@ A real (non-probe) sweep is appropriate when the user explicitly asks to refresh
128
157
  - Data first: show the numbers before the interpretation
129
158
  - For AI visibility, lead with the mention transition, then the citation change. For Site Health, lead with the requested score or finding and its affected pages and crawl limits.
130
159
  - Action-oriented: every observation ends with a recommended next step
160
+ - Rest each recommended priority on a measured fact: the Property or metro
161
+ and the answer counts behind it. A cause is a hypothesis: label it, and
162
+ name the read that would test it. Never state expected gains or timelines.
163
+ - Name only engines, settings, channels, and integrations that a tool
164
+ returned.
131
165
  - Answer in the smallest shape that carries the data. One ranked table,
132
166
  not several split by tier. Right-align numeric columns and keep the
133
167
  numerator and denominator beside every percentage.
@@ -39,14 +39,16 @@ invented combined share-of-voice ratio.
39
39
  | Question | Stored evidence |
40
40
  |---|---|
41
41
  | How is a Simple portfolio doing? | `canonry_project_overview`, `canonry_visibility_stats`; preserve sample sizes and returned class |
42
- | Which Advanced Properties are strongest or weakest? | `canonry_measurement_portfolio_summary`; use `mentionRanking.strongest`, `.weakest`, and `.excluded`, plus `tiedAtWeakest`. Keep the default `limit`; narrow with `groupKey` for more rows |
42
+ | Which Advanced Properties are strongest or weakest? | `canonry_measurement_portfolio_summary`; use `mentionRanking.strongest`, `.weakest`, and `.excluded`, plus `tiedAtWeakest`. It returns at most 4 rows, with or without `groupKey`; pass `groupKey` for one metro's weakest, or page `canonry_measurement_overview` for more |
43
+ | Which metros have the biggest gaps? | The portfolio summary's `markets` (every metro, worst-first, whatever the limit) and `tiedAtWeakest.byMetro`; `groupKey` lists one metro's submarkets |
43
44
  | What is measured for one Property or market? | `canonry_measurement_overview` with `scope: property` / `targetKey` or `scope: group` / `groupKey` |
44
45
  | Which questions explain a Property's gaps? | `canonry_measurement_property_questions`, then `canonry_measurement_question_result` with a returned `resultId` |
45
46
  | What was mentioned or linked in individual answers? | `canonry_measurement_property_evidence` with `shape: answers` |
46
- | Who appeared instead? | `namedInsteadInAnswerText` on each weakest row, or `canonry_measurement_property_competitors` for one Property; these names were written in the answer text, not cited |
47
- | Where do engines get these answers? | `citedDomains` on each weakest row and `weakestAnswerSources` in the portfolio summary; project-wide, `canonry_analytics_sources` with `queryClass` and `runId` set, since without them it pools both classes and every sweep |
48
- | Did performance change? | `populations[].comparison` from `canonry_visibility_report` for the displayed selection (Simple and Advanced); `canonry_measurement_changes` for changed Properties (Advanced); `canonry_visibility_compare` for calendar-month comparisons (Simple, or Advanced with a Property, group or market `scope`) |
49
- | Can these results support a conclusion? | `canonry_measurement_data_quality` for Advanced completeness, capture, retrieval, and comparability |
47
+ | Who appeared instead of one Property? | `canonry_measurement_property_competitors`, or `namedInsteadInAnswerText` on a weakest row; these names were written in the answer text, not cited |
48
+ | Who do answers name instead across the portfolio? | `canonry_competitor_landscape` with `queryClass` and `runId: latest`; `tiedAtWeakest.namedInstead` for the weakest tie. Per-Property lists are samples of weak Properties, never a portfolio ranking |
49
+ | Where do engines get these answers? | For one Property, `citedDomains` from `canonry_measurement_property_competitors`. Project-wide, `canonry_analytics_sources` with `queryClass` and `runId: latest`, since without them it pools both classes and every sweep. `weakestAnswerSources` pools the weakest rows and the tie; never present it as one Property's sources |
50
+ | Did performance change? | `canonry_measurement_changes` once per class for changed Properties (Advanced): rows come largest move first, `distribution` counts every Property, `withinNoise` marks noise. `populations[].comparison` from `canonry_visibility_report` for the displayed selection's headline; `canonry_visibility_compare` for calendar-month comparisons (Simple, or Advanced with a Property, group or market `scope`) |
51
+ | Is the sweep complete, or is anything unreliable? | `canonry_measurement_data_quality`: quote completeness `expected`, `executed` and `missing`, plus `unattributedByClass` (per class, never pooled) and `latestFill`; it also reports capture, retrieval and comparability. Then `canonry_run_completeness` with `run.displayedRunId` for missing answers per engine. A Healthy run status and `canonry_doctor` are not completeness checks |
50
52
 
51
53
  For schema-v1 plans use `canonry_measurement_report` pinned to the requested
52
54
  revision. Do not assume v2 Property/question-class reads are supported or
@@ -90,19 +92,31 @@ incompatible comparison with its reason instead of subtracting rates by hand.
90
92
  For Simple month comparisons, honor the returned interval, continuity,
91
93
  `within-noise`, and low-sample qualifications from the reporting playbook.
92
94
 
95
+ Between two sweeps, a Property that moved 2 answers or fewer
96
+ (`withinNoise: true`) is within noise: one answer on a 12-answer denominator
97
+ is 8.3 points. Never call it real, a trend, structural, or a regression.
98
+ Report the `distribution` (how many Properties improved, declined, moved
99
+ within noise, or did not change), and flag only moves beyond noise, or the
100
+ same move repeated over several sweeps, as worth checking.
101
+
93
102
  Carry the displayed run and the supported filters into follow-up reads.
94
103
  Reuse cursors unchanged with the same scope, class, sort, shape, and filters.
95
104
  A revision or evidence change can invalidate a cursor; restart that read
96
105
  without merging pages from incompatible snapshots. Overview search narrows
97
106
  displayed rows without changing metric denominators.
98
107
 
99
- Tool output can be trimmed. Inspect `__truncated`, `__truncation` (the keys
100
- dropped and `k of n` items kept per list), `__omittedRows`, and
101
- `__omittedRowsByField` as well as API pagination metadata. A text result that
102
- ends with a `__truncation:` line and the truncation note is a partial slice.
103
- Never list, rank, count, or group items you did not see; say what was cut,
104
- then request a smaller page or narrower scope (a `groupKey` for the portfolio
105
- summary) before treating the returned rows as exhaustive.
108
+ Tool output can be trimmed. Inspect `__partialLists` (the first field when
109
+ present: each list the tool itself returned only part of, as shown of total),
110
+ `__truncated`, `__truncation` (the keys dropped, `k of n` items kept per list,
111
+ and under `cursors` any page cursor that now skips cut rows), `__omittedRows`,
112
+ and `__omittedRowsByField` as well as API pagination metadata: `truncated: true`,
113
+ a total (`totalProperties`, `total`, `questionTotal`) above the rows
114
+ returned, or a `nextCursor`. A text result that ends with a `__truncation:`
115
+ line and the truncation note is a partial slice. Never list, rank, count, or
116
+ group items you did not see; say how many of how many you saw, never call
117
+ the rows the biggest, strongest, all, or the full picture, then request a
118
+ smaller page or narrower scope (a `groupKey` for the portfolio summary)
119
+ before treating the returned rows as exhaustive.
106
120
 
107
121
  Lead the answer with the scoped result, give numerator/denominator and the
108
122
  evidence explaining it, state missing data or comparison limits, then suggest
@@ -73,6 +73,7 @@ Behaviors worth knowing before narrating numbers from the report:
73
73
  - `citationsTrend` excludes partial runs. A project with only one completed run shows `trend: "unknown"` — never claim a comparison that isn't there.
74
74
  - Project ownership and competitor tagging use subdomain-aware matching: `blog.example.com` counts as the project when `example.com` is the canonical domain or in `ownedDomains`; `blog.rival.com` is tagged `isCompetitor: true` when `rival.com` is tracked.
75
75
  - AI referral totals dedupe overlapping GA4 attribution dimensions (`session` / `first_user` / `manual_utm`).
76
+ - Server-side AI referral sessions (the "AI Visibility — Server-Side" section) are server-log counts, not GA sessions. Before quoting one, run `cnry traffic referral-assessment <project> --start-date YYYY-MM-DD --end-date YYYY-MM-DD --format json` for the same dates (or read the silent `report.ai-referral-bursts` doctor check for the report month) and review any candidate bursts. Quote the unchanged headline (the assessment's `totals.countable`) beside the separate `totals.adjustedEstimate`; a candidate burst is not confirmed automation, and neither figure replaces GA.
76
77
 
77
78
  The hand-rolled templates below are still the right call when the user wants a focused weekly/monthly digest with custom regression and gain narratives that the bundled report doesn't surface.
78
79
 
@@ -50,6 +50,9 @@ cnry project list # list all projects
50
50
  cnry project create <name> --domain <url> --country US --language en
51
51
  cnry project show <name> # project detail
52
52
  cnry project update <name> # update project settings
53
+ cnry project update <name> --dispatch-mode claude=batch # scheduled sweeps batch this provider (repeatable; see Batch mode below)
54
+ cnry project update <name> --clear-dispatch-mode claude # back to sync
55
+ cnry project update <name> --negative-review-max-stars 2 # review.negative for 1-2 star Google reviews only (1-4, or "default" for 3)
53
56
  cnry project delete <name> # delete a project
54
57
  cnry project delete <name> --dry-run # preview cascade impact (GET /delete-preview) without writing
55
58
  cnry status <project> # mention + citation summary + domain info
@@ -99,11 +102,12 @@ cnry snapshot "Acme Corp" --domain acme.example.com --provider-mode browser --qu
99
102
  cnry run <project> # sweep all configured providers
100
103
  cnry run <project> --provider gemini # single provider only
101
104
  cnry run <project> --query "alpha" --query "beta" # scope sweep to a subset of tracked queries (repeatable)
102
- cnry run <project> --wait # block until complete
105
+ cnry run <project> --wait # block until complete (a batch run: until batch-pending, then exit 0); exit 2 if the run failed
103
106
  cnry run <project> --location <label> # run with specific location context
104
107
  cnry run <project> --all-locations # run for every configured location
105
108
  cnry run <project> --no-location # explicitly skip location context
106
109
  cnry run <project> --probe --provider openai --query "..." # operator/agent test run — snapshot is inspectable but EXCLUDED from dashboard, analytics, intelligence, report, and notifications. Use for verification / "did this fix work?" / regression hypothesis testing.
110
+ cnry run <project> --dispatch-mode batch # send every eligible provider to its batch API (half-price tokens; answers can take up to the deadline)
107
111
  cnry run --all --wait # all projects
108
112
  cnry run cancel <project> [run-id] # force-cancel stuck runs
109
113
  cnry run completeness <run-id> # answered vs missing answers per provider, and whether a fill is allowed
@@ -117,7 +121,47 @@ Run statuses: `queued` → `running` → `completed` / `failed` / `partial`
117
121
 
118
122
  `partial` = some providers failed (usually rate limits) — successful snapshots are still saved.
119
123
 
120
- A partial run of a published measurement plan can be finished with `cnry run fill <run-id>`: it asks only the questions that have no answer yet, writes them into the same run, and marks the run `completed` once every expected answer exists. The run keeps its id, timestamps and place in history, so reports show one sweep, never two. It is refused (with a reason code) when the plan was republished since, the run started more than 24 hours ago, a newer sweep exists, or a missing answer has no frozen model. A provider that fails 3 times in a row is stopped for that fill; fill again once its limit lifts. Do not re-run the whole sweep to recover a few failed answers.
124
+ `--wait` exit code (`run <project>`, `--all-locations`, and `--all`): the run detail, location table, or JSON prints in full first, then the exit code reports the final status.
125
+
126
+ - `failed` → exit `2`: every provider call failed (for example an invalid API key), so fix the cause and retry. stderr carries `{ "error": { "code": "RUN_FAILED", "message", "details": { "waitedRunCount", "failedRuns": [{ "runId", "project", "location"?, "error" }] } } }`; stdout is unchanged.
127
+ - `--all --wait` and `--all-locations --wait` exit `2` when any waited run failed. A project whose trigger returned an `error` row has no run to wait on and does not count.
128
+ - `completed`, `partial`, and `cancelled` → exit `0`. A partial run kept its answers, so finish it with `cnry run fill` instead of re-running the sweep. A cancelled run was stopped by an operator.
129
+ - A batch-pending run (see Batch mode below) still reads `running`, so `--wait` stops there and exits `0`.
130
+ - Without `--wait` the command exits `0` once the run is queued; read the outcome with `cnry run show <id>`.
131
+
132
+ A partial run of a published measurement plan can be finished with `cnry run fill <run-id>`: it asks only the questions that have no answer yet, writes them into the same run, and marks the run `completed` once every expected answer exists. The run keeps its id, timestamps and place in history, so reports show one sweep, never two. It is refused (with a reason code) when the plan was republished since, the run started more than 24 hours ago (for a run that used batch mode: finished more than 24 hours ago), a newer sweep exists, or a missing answer has no frozen model. A provider that fails 3 times in a row is stopped for that fill; fill again once its limit lifts. Do not re-run the whole sweep to recover a few failed answers.
133
+
134
+ ### Batch mode
135
+
136
+ Scheduled sweeps can send a provider's answers to its asynchronous batch API
137
+ (Claude today) at half the token price. It needs `providers.<name>.batch.enabled:
138
+ true` in `config.yaml` and the project's `--dispatch-mode <provider>=batch`.
139
+ Manual runs stay sync unless given `--dispatch-mode batch`, which is refused
140
+ (exit 1, `details.ineligible` names each provider's reason) when no provider can
141
+ batch. Only a full sweep of a published plan batches: planless runs, slices and
142
+ probes always run sync. While a batch is outstanding the run stays `running`,
143
+ `cnry run show <id>` prints `waiting on provider batch: ...`, and the project's
144
+ next scheduled sweep is skipped. `--wait` (also with `--all` and
145
+ `--all-locations`) never waits for a batch to end: once the run is
146
+ batch-pending (a batch `submitted` or `ended`) it stops polling and exits 0.
147
+ Text output adds `Waiting on provider batch(es): <provider> — N requests,
148
+ submitted <t>, deadline <t>; check with canonry run show <id>` per
149
+ batch-pending run. With `--format json`, `cnry run <project> --wait` prints the
150
+ run detail unchanged (`status: running`, outstanding `providerBatches`), and
151
+ `--all-locations --wait` prints each location's trigger row merged with its
152
+ run detail. `cnry run --all --wait --format json` prints only
153
+ `{ project, runId, status, location }` rows (plus `error`): a batch-pending
154
+ run reads `status: running` with no `providerBatches`, so read those with
155
+ `cnry run show <runId> --format json`. Treat that as success, not a timeout,
156
+ and read the outcome later with `cnry run show <id>`; re-triggering gets
157
+ `RUN_IN_PROGRESS`. Missing answers
158
+ after the deadline make the run `partial`; fill them with `cnry run fill` (sync
159
+ price, 24 hours after the run finalized). `cnry run show` also prints tokens,
160
+ searches and estimated cost per provider and tier (a priced cost under $0.00005
161
+ prints `<$0.0001`; only a real zero prints `$0.0000`); `--format json` is the
162
+ API response (`dispatchModes`,
163
+ `providerBatches`, `usage`). Batch mode is not zero-data-retention eligible on
164
+ Anthropic, so keep it off on deployments that need ZDR.
121
165
 
122
166
  ### Probe vs real runs
123
167
 
@@ -416,13 +460,16 @@ cnry settings --format json
416
460
  cnry settings provider gemini --api-key <KEY> --model gemini-2.5-flash
417
461
  cnry settings provider openai --max-per-day 1000 --max-per-minute 20
418
462
  cnry settings provider perplexity --api-key <KEY> --model fast
463
+ cnry settings provider muse --api-key <KEY> --model muse-spark-1.3
419
464
  ```
420
465
 
421
466
  Perplexity runs on its Agent API. `--model` takes a preset (`fast` default, `low`, `medium`, `high`, `xhigh`) or a `vendor/model` slug such as `perplexity/sonar`. Retired Sonar names still work and run as their replacement (`sonar` → `fast`, `sonar-pro` → `low`).
422
467
 
423
468
  Quota flags: `--max-concurrent`, `--max-per-minute`, `--max-per-day`
424
469
 
425
- Available providers: `gemini`, `openai`, `claude`, `perplexity`, `local`, `cdp`
470
+ Available providers: `gemini`, `openai`, `claude`, `perplexity`, `muse`, `local`, `cdp`
471
+
472
+ Set `MUSE_API_KEY` before `cnry bootstrap` or `cnry init` to store the key. Setup also accepts `MUSE_MODEL` and `MUSE_BASE_URL`. `cnry init --muse-key <KEY>` stores a key directly. Muse uses Meta's Standard `muse-spark-1.3` by default. Contributor model IDs require an explicit model override; Meta permits training on their prompts and completions. See [the Muse provider guide](https://github.com/Canonry/canonry/blob/main/docs/providers/muse.md).
426
473
 
427
474
  If a provider hits rate limits (429 errors), the run completes as `partial`. Reduce concurrency or increase time between sweeps.
428
475
 
@@ -502,6 +549,15 @@ cnry google performance <project> --order-by impressions --limit 2000 --offset 2
502
549
  cnry google performance-daily <project> # per-day series + property-level window totals
503
550
  cnry google top-pages <project> # pages ranked by clicks, aggregated in SQL
504
551
  cnry google top-pages <project> --start 2026-06-01 --end 2026-06-30 --limit 20
552
+ cnry google query-totals <project> --start 2026-06-01 --end 2026-06-30
553
+ cnry google query-totals <project> --window 90d --limit 500 --offset 500
554
+ # One row per named query over the window: clicks, impressions, CTR, position
555
+ # weighted by impressions, and `days` (the dates the query appeared). Ordered
556
+ # clicks desc, impressions desc, query asc; page with --limit/--offset until
557
+ # `truncated` is false. `source` marks rows that use the legacy page table for
558
+ # some days (`page-summed` or `mixed`: impressions over-count). Google leaves
559
+ # rare queries out, so never sum these rows for a property total, use
560
+ # `cnry google performance-daily`. Reads stored data only, never calls Google.
505
561
 
506
562
  cnry google inspect <project> <url> # inspect specific URL
507
563
  cnry google inspect-sitemap <project> --wait # bulk inspect all sitemap URLs
@@ -638,6 +694,7 @@ cnry traffic sync <project> --source <source-id> # pull adapters, including
638
694
  cnry traffic sources <project> --format json
639
695
  cnry traffic status <project> --format json
640
696
  cnry traffic events <project> --source <source-id> --format json
697
+ cnry traffic referral-assessment <project> --start-date 2026-08-01 --end-date 2026-08-31 --burst-threshold 100 --ratio-threshold 3 --limit 100 --format json
641
698
 
642
699
  cnry doctor --project <project> --check 'traffic.source.*' --format json
643
700
  cnry schedule show <project> --kind traffic-sync --format json
@@ -658,6 +715,33 @@ the schedule interval.
658
715
  Read the [server-side traffic guide](server-side-traffic.md) for token safety,
659
716
  route checks, activation order, smoke tests, rollback, and troubleshooting.
660
717
 
718
+ `traffic referral-assessment` is a DB-only diagnostic, also available as MCP
719
+ `canonry_traffic_referral_assessment`. Date bounds are inclusive UTC dates, at
720
+ most 366 days. `--source` optionally selects one source. Thresholds tune only
721
+ the read; `--limit` caps candidate details, never totals. JSON and JSONL both
722
+ return the full assessment object.
723
+
724
+ Raw counts and existing headlines stay unchanged. The separate adjusted
725
+ estimate excludes every hit in a candidate burst group (one source, product,
726
+ normalized path and UTC hour), not proven automation.
727
+ The default 100-hit threshold is an uncalibrated review trigger. Normalized
728
+ paths may combine multiple pages, and sources may overlap. Simple and Advanced
729
+ projects use project/source scope; Property, Target and market attribution are
730
+ unavailable and unsupported filters are rejected.
731
+
732
+ `observedRatio` is a descriptive server/GA quotient, rounded to 2 decimals,
733
+ when GA sessions are positive and server rows exist.
734
+ `observedRatioAboveThreshold` compares that rounded value with
735
+ `--ratio-threshold`; it is not a quality verdict. Complete matching coverage and
736
+ the GA timezone are unknown. Server and GA observation states distinguish
737
+ missing evidence from an observed zero: server rows that are all redirects or
738
+ subresources are observed zero, and GA is observed zero only when the window
739
+ lies inside the latest GA sync window and that sync stored no AI row for it.
740
+ The silent `report.ai-referral-bursts` doctor check warns only when candidate
741
+ bursts exist, reports `candidateGroups`, and carries these limits in its
742
+ details. Keep GA evidence; this assessment does not establish a replacement
743
+ human-visit count.
744
+
661
745
  ## Google Analytics 4
662
746
 
663
747
  GA4 integration supports service-account auth and OAuth (`canonry google connect <project> --type ga4`). With OAuth, `ga properties` lists the readable properties so the numeric id `ga connect --property-id` needs can be discovered without leaving canonry. The service account must have Viewer access on the GA4 property. `ga sync` writes to four DB tables (`gaTrafficSnapshots`, `gaAiReferrals`, `gaSocialReferrals`, `gaTrafficSummaries`); every subsequent read command queries the local store rather than re-fetching from GA4, so reads are fast and quotaless. AI-referral rows are tracked across 10 known providers (chatgpt, perplexity, claude, gemini, openai, anthropic, copilot, phind, you.com, meta.ai), three GA4 attribution dimensions (`session` / `first_user` / `manual_utm`), and joined to landing pages. Social referrals are split Organic vs Paid via GA4's `sessionDefaultChannelGroup`. All commands support `--format json`.
@@ -764,6 +848,12 @@ cnry gbp places <project> [--location locations/{n}]
764
848
  # latest Places-API rendered-listing snapshot per location: the
765
849
  # server-derived `amenities` the public listing advertises (#648 cross-reference).
766
850
  # Needs a Places API key (places.apiKey / GOOGLE_PLACES_API_KEY)
851
+ cnry gbp reviews <project> [--location locations/{n}] [--negative] [--limit N]
852
+ # stored reviews newest first, plus each location's Business Profile
853
+ # review access (ok / unavailable + reason / error) and latest rating.
854
+ # --negative keeps reviews at or below the project's threshold
855
+ # (the review.negative threshold, 3 stars by default);
856
+ # each review carries its webhook alertState
767
857
  cnry gbp summary <project> [--location locations/{n}]
768
858
  # composite scorecard: performance totals + recent-vs-prior 7d
769
859
  # deltas (deltaPct null when prior=0), keyword coverage,
@@ -772,7 +862,11 @@ cnry gbp summary <project> [--location locations/{n}]
772
862
  # description / service area / hours / phone + closed-status counts)
773
863
  ```
774
864
 
775
- `gbp sync` produces a run with the standard statuses (`completed` / `partial` / `failed`); `partial` means some selected locations synced and others errored (the per-location errors are on the run). Non-lodging locations are skipped cleanly (Google answers the lodging call with HTTP 400, not 404). Reviews are **not** synced — the v4 Reviews API is producer-restricted by Google and unavailable on most projects; the Q&A API was retired (2025-11-03).
865
+ `gbp sync` produces a run with the standard statuses (`completed` / `partial` / `failed`); `partial` means some selected locations synced and others errored (the per-location errors are on the run). Non-lodging locations are skipped cleanly (Google answers the lodging call with HTTP 400, not 404). The Q&A API was retired (2025-11-03).
866
+
867
+ Reviews sync from the v4 Reviews API when Google has enabled it for the Cloud project. Most projects get `403 SERVICE_DISABLED`, which never fails the run: `gbp reviews` shows it per location, and the `gbp.reviews.access` doctor check explains how to request access. Without v4 access, locations fall back to the public Places listing when Places runs on the `atmosphere` tier with a key: the overall rating and at most five reviews chosen by relevance, so a new review can be missed. That fallback is at most one Place Details call per location per day, billed at Enterprise + Atmosphere.
868
+
869
+ Review webhooks: subscribe with `cnry notify add <project> --webhook <url> --events review.negative,review.rating-dropped`. `review.negative` is sent once per new or edited review at or below the project's threshold, 3 stars by default (`cnry project update <project> --negative-review-max-stars <1-4|default>`, or `spec.negativeReviewMaxStars` in `canonry apply`); `review.rating-dropped` when a location's public rating falls (Places fallback only). Lowering the threshold also stops alerts already queued; raising it applies to new and edited reviews only. A location's first sync records a baseline and sends nothing, a review older than 30 days never alerts, and nothing is held for a webhook added later. Discord and Slack URLs get a formatted message.
776
870
 
777
871
  ## Google Ads + Google Tag Manager conversion integrity
778
872
 
@@ -1304,7 +1398,7 @@ Every command takes `--format`:
1304
1398
 
1305
1399
  **Update notice.** When a newer canonry is published, the CLI writes one line to **stderr** before the command runs (stdout is never touched): `[canonry] UPDATE_AVAILABLE: ...` in text mode, or `{"notice":{"code":"UPDATE_AVAILABLE","current":"…","latest":"…","installMethod":"npm|homebrew|docker","upgradeCommand":"…","url":"…"}}` with `--format json|jsonl`. Tell the operator, or run the `upgradeCommand` (it already matches how canonry was installed) and restart `canonry serve`. `cnry doctor --check canonry.version.current` reports the same for the running server (`version.outdated` warns). Silence with `CANONRY_DISABLE_UPDATE_CHECK=1`. Over MCP the same notice arrives in the initialize instructions and as `updateAvailable` in `canonry_help`.
1306
1400
 
1307
- `jsonl` is supported by every **collection** command — one whose primary output is a list: `insights`, `runs`, `evidence`, `history`, `query/keyword/competitor list`, `notify list/events`, `google` reads (`performance`, `performance-daily`, `inspections`, `coverage-history`, `deindexed`, `status`, `properties`, `list-sitemaps`), `bing` reads (`coverage-history`, `inspections`, `performance`, `sites`), `ga` reads (`ai-referral-daily`, `ai-referral-history`, `social-referral-history`, `session-history`, `coverage`), `google-ads` customer/snapshot reads, `gtm` account/container/workspace/snapshot reads, conversion-tracking contracts and integrity findings, `ads geo search` and `ads conversions` reads, `traffic events/sources/status`, `discover list/show`, `content targets/sources/gaps/map`, `backlinks list/releases`, `project list/locations`, `key list`, `agent memory list`, `agent providers`, `sources` (streams the ranked cited-domain list), and `doctor`. (`content brief` is an object command — `jsonl` degrades to its JSON document.)
1401
+ `jsonl` is supported by every **collection** command — one whose primary output is a list: `insights`, `runs`, `evidence`, `history`, `query/keyword/competitor list`, `notify list/events`, `google` reads (`performance`, `performance-daily`, `query-totals`, `inspections`, `coverage-history`, `deindexed`, `status`, `properties`, `list-sitemaps`), `bing` reads (`coverage-history`, `inspections`, `performance`, `sites`), `ga` reads (`ai-referral-daily`, `ai-referral-history`, `social-referral-history`, `session-history`, `coverage`), `google-ads` customer/snapshot reads, `gtm` account/container/workspace/snapshot reads, conversion-tracking contracts and integrity findings, `ads geo search` and `ads conversions` reads, `traffic events/sources/status`, `discover list/show`, `content targets/sources/gaps/map`, `backlinks list/releases`, `project list/locations`, `key list`, `agent memory list`, `agent providers`, `sources` (streams the ranked cited-domain list), and `doctor`. (`content brief` is an object command — `jsonl` degrades to its JSON document.)
1308
1402
 
1309
1403
  Each `jsonl` line re-injects the envelope context it would otherwise lose, so a line lifted out still self-describes:
1310
1404
 
@@ -1327,6 +1421,7 @@ Compact reference for the composite / keyed commands agents read most (shapes ca
1327
1421
  | `cnry sources <p> [--rank] [--limit N] [--by-provider] [--window …]` | `SourceBreakdownDto{ overall[], byQuery, ranked, byProvider, runId, window, limit }` @ `contracts/analytics.ts`. `ranked`/each `byProvider[name]` = `RankedSourceList{ totalCitedSlots, domainTotal, entries[], truncatedDomainCount, truncatedCitedSlots, bySurfaceClass[] }`; `entries[]`=`SourceRankEntry{ domain, count, percentage, category, label, surfaceClass }`; `bySurfaceClass[]`=`SurfaceClassCount{ surfaceClass, label, count, percentage, domainCount }`. `surfaceClass` ∈ own \| direct-competitor \| ota-aggregator \| editorial-media \| other. | ✅ streams `ranked.entries` one / line as `{project, …entry}` |
1328
1422
  | `cnry visibility-stats <p> [--since <iso>] [--until <iso>] [--month <YYYY-MM>] [--last-runs N] [--by-provider] [--share-of-voice] [--query-class branded\|non-brand]` | `VisibilityStatsDto{ project, groupBy, window{since,until,lastRuns,runCount}, totals, byProvider?[], queries[], shareOfVoice? }` @ `contracts/visibility-stats.ts`. Each query / provider / totals entry = `{ total, checked, mentioned, cited, mentionRate, citedRate }` (+ `query`/`queryId`/`firstObserved`/`lastObserved` on queries, + `provider`/observed on provider entries). `checked`=snapshots with non-null `answerMentioned` (tri-state n for mention); `mentionRate=mentioned/checked`, `citedRate=cited/total`, both `null` on a 0 denominator. `byProvider`/per-query `providers` present only with `--by-provider`; counts sum to pooled. `--month YYYY-MM` echoes the resolved `window.since`/`until`. `shareOfVoice` present only with `--share-of-voice` = `{ queryClass, percent, projectMentions, competitorMentions, snapshotsWithAnswerText, perCompetitor[{domain,mentions}] }`; `percent` (0-100) = `projectMentions/(projectMentions+competitorMentions)`, `null` when no competitors configured. `queryClass` is what was actually served: `non-brand` (the default), `branded` (via `--query-class`), or `pooled` — which appears ONLY when the project has no usable brand alias to split by, never as a default. Branded and non-brand never share a denominator. | ✅ streams `queries` one / line as `{project, runCount, …query}` (envelope-only `shareOfVoice` not in the jsonl rows) |
1329
1423
  | `cnry google coverage <p>` (index coverage) | `{ summary{total,indexed,notIndexed,deindexed,percentage}, lastInspectedAt, lastSyncedAt, indexed[], notIndexed[], deindexed[], reasonGroups[] }` — `GscCoverageSummaryDto` @ `contracts/google.ts`. `indexed[]`/`notIndexed[]`=`GscUrlInspectionDto`, `deindexed[]`=`GscDeindexedRowDto`. | → degrades to the `json` document. The single-array reads `google inspections` / `coverage-history` / `deindexed` **stream** `jsonl`. |
1424
+ | `cnry google query-totals <p> [--start …] [--end …] [--window …] [--limit N] [--offset N]` | `GscQueryTotalsDto{ rows[], totalMatching, truncated, window }` @ `contracts/google.ts`. Each row = `{ query, clicks, impressions, ctr, position, days, source(google\|page-summed\|mixed) }`; `window` = `GscWindowRange{ startDate, endDate, latestDataDate, daysSinceLatestData }`. Named queries only: the rows do not add up to the property total. | Streams one line per query, each tagged with `project` and `window`. |
1330
1425
  | `cnry ga measurement-analysis <p> [--window 30d\|60d\|90d] [--host-scope marketing\|all] [--path-prefix /…]` | `GaMeasurementAnalysisDto` @ `contracts/measurement.ts`: fixed 30-day `acquisition.periods/channels/pages`, configured-event `leads.periods/channels` with explicit attribution/filter scope, and `searchDemand.periods/queries/pages` with property totals, reported branded/non-brand rows, and unreported residuals. Each GA component carries independent status/error/sync freshness. | → degrades to the `json` document |
1331
1426
  | `cnry ga traffic <p> [--window …]` | Object summary — `GA4TrafficSummaryDto` / `GaTrafficResponse` @ `contracts/ga.ts`: `{ totalSessions, totalOrganicSessions, totalDirectSessions, totalUsers, aiSessionsDeduped, paidAiSessionsDeduped, organicAiSessionsDeduped, aiSessionsBySession, paidAiSessionsBySession, organicAiSessionsBySession, socialSessions, socialUsers, channelBreakdown{organic,social,direct,ai,other→{sessions,sharePct,sharePctDisplay}}, *SharePct (+ `*Display`), topPages[], aiReferrals[], aiReferralLandingPages[], socialReferrals[], lastSyncedAt, periodStart, periodEnd }`. **AI referrals are sessions only.** The six `ai*Users*` fields and the `users` on `aiReferrals[]` / `aiReferralLandingPages[]` were withdrawn in 4.135.0 — GA reports users as a COUNT DISTINCT at the grain requested, so the stored column re-counted one visitor per landing page, medium, channel and date, and no un-dimensioned AI-referral fetch exists to supply a correct figure. `totalUsers` and `socialUsers` are unaffected. | → degrades to the `json` document |
1332
1427
  | `cnry ga attribution <p> [--trend]` | Object — a **renamed projection** of `GaTrafficResponse` (⚠️ field names differ from the DTO): `aiSessions`(←`aiSessionsDeduped`), `organicSessions`(←`totalOrganicSessions`), `directSessions`(←`totalDirectSessions`), plus `totalSessions, totalUsers, paidAiSessions, organicAiSessions, aiSessionsBySession, paidAiSessionsBySession, organicAiSessionsBySession, socialSessions, socialUsers, {ai,social,organic,direct}SharePct (+ `*Display`), otherSessions, otherSharePct, channelBreakdown, aiReferrals[], aiReferralLandingPages[], socialReferrals[], periodStart, periodEnd`. With `--trend`: drops `periodStart/End`, adds `trend` (`GaAttributionTrendResponse`). Assembled inline in `commands/ga.ts`. | → degrades to the `json` document |
@@ -11,7 +11,8 @@ Canonry integrates with the Google Business Profile (GBP) API to surface local A
11
11
  - Sync daily performance metrics — impressions, website clicks, call clicks, direction requests (all 11 `DailyMetric`s)
12
12
  - For hotels: sync lodging attributes (amenities, accessibility, pets, etc.) and place action links (booking CTAs)
13
13
  - Roll the above into a composite summary scorecard (`canonry gbp summary`)
14
- - Sync reviews per location — **only where Google has granted v4 access** (gated; unavailable on most projects — see below)
14
+ - Sync reviews per location from the v4 API **where Google has granted v4 access** (gated; unavailable on most projects, see below). Elsewhere, fall back to the public Places listing: the overall rating and at most five reviews by relevance (`atmosphere` Places tier with a key)
15
+ - Alert on negative reviews: `review.negative` webhooks for new or edited reviews at or below the project's threshold (1-3 stars by default), `review.rating-dropped` when the public rating falls (see "Review alerts" below)
15
16
 
16
17
  ## What Stays Manual
17
18
 
@@ -83,6 +84,28 @@ What we confirmed, with a project approved and running the v1 family at 300 QPM,
83
84
 
84
85
  **Conclusion:** the v4 GMB API is gated independently of the v1 approval and Google controls the switch. The only routes are (1) the **shortcut "enable" link from the access-approval email**, opened as the approved account in the browser, or (2) replying to the access-request thread asking Google to enable `mybusiness.googleapis.com` for your project number. Self-service (library, gcloud) does not work. Build reviews behind this gate and ship the rest without it.
85
86
 
87
+ ### Review alerts
88
+
89
+ `cnry gbp sync` tries v4 reviews for every selected location. The outcome is stored per location and shown by `cnry gbp reviews <project>` and the `gbp.reviews.access` doctor check; a `403 SERVICE_DISABLED` never fails the sync. Until Google enables v4, locations fall back to the public Places listing when Places runs on the `atmosphere` tier with an API key and the location has a Maps place id. That fallback costs at most one Place Details call per location per day (Enterprise + Atmosphere SKU) and stops by itself once v4 works.
90
+
91
+ | | Business Profile v4 | Places fallback |
92
+ |---|---|---|
93
+ | Reviews seen | Every review, including edits and owner replies | At most five, chosen by relevance, no replies |
94
+ | `review.negative` | Each new or edited review at or below the threshold | A review at or below the threshold that appears among the five |
95
+ | `review.rating-dropped` | Never (each review alerts on its own) | When the one-decimal public rating falls |
96
+
97
+ The threshold is per project: 3 stars by default, set with `cnry project update <project> --negative-review-max-stars <1-4|default>` or `spec.negativeReviewMaxStars` in `canonry apply`. A hotel with hundreds of reviews may want 2; a small business may want every 3-star review.
98
+
99
+ Rules that hold for both sources:
100
+
101
+ - A location's first sync per source records a baseline and sends nothing, so connecting never replays old reviews.
102
+ - A review older than 30 days never alerts. This keeps an old review that the Places listing surfaces by relevance from reading as new, and keeps a webhook that was broken for weeks from replaying a month of reviews once fixed.
103
+ - A rating or text edit re-alerts; a new timestamp or an owner reply alone does not.
104
+ - An alert is marked sent only after a destination accepts it, and retried on the next sync when every delivery failed. With no subscribed webhook it is skipped, not held.
105
+ - Once v4 covers a location, queued Places alerts for it are suppressed so nothing is sent twice.
106
+
107
+ Subscribe: `cnry notify add <project> --webhook <url> --events review.negative,review.rating-dropped`. Discord and Slack webhook URLs get a formatted message; any other URL gets the signed JSON payload. The doctor check warns only when a project subscribes to review events and some location has no review source at all; missing v4 access alone reports as skipped, since it is the normal state.
108
+
86
109
  **Account-credential gotcha:** API calls use your **Application Default Credentials** (`gcloud auth application-default login` → `print-access-token`), while `gcloud services enable` uses the separate **gcloud CLI account** (`gcloud config get-value account`). These can be different identities. Verify the token's real account with `curl "https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=$TOKEN"` before concluding anything about access — a "wrong account" symptom is often just the two credential stores disagreeing.
87
110
 
88
111
  ### Create OAuth client credentials
@@ -262,7 +285,7 @@ Every GBP number belongs to one of three planes. **Tag each figure by plane befo
262
285
 
263
286
  ## Real-World Data Shapes & Signal Patterns
264
287
 
265
- Validated against three live businesses of different types (a computer-support shop, a roofing contractor, and a beachfront boutique hotel). Bake these into any parsing or analysis code.
288
+ Validated against three live businesses of different types (a computer-support shop, a roofing contractor, and a beachfront boutique hotel). Bake these into any parsing or analysis code. Counts marked illustrative below are stand-ins for the observed values; the patterns are what was observed.
266
289
 
267
290
  ### Response-shape quirks (the parser MUST handle these)
268
291
 
@@ -272,10 +295,10 @@ Validated against three live businesses of different types (a computer-support s
272
295
 
273
296
  ### Signal patterns (what the data actually looks like)
274
297
 
275
- - **`BUSINESS_DIRECTION_REQUESTS` is the most reliably-populated conversion signal** across every business type — even a tiny roofing contractor logged 58/30d while its website-clicks (2) and call-clicks (1) were near-zero. For local/service businesses it's the headline AEO-conversion proxy, not website clicks.
298
+ - **`BUSINESS_DIRECTION_REQUESTS` is the most reliably-populated conversion signal** across every business type — even a tiny roofing contractor logged 58/30d (illustrative) while its website-clicks (2) and call-clicks (1) were near-zero. For local/service businesses it's the headline AEO-conversion proxy, not website clicks.
276
299
  - **Most of the 11 daily metrics are all-zero** for non-retail businesses (`BUSINESS_CONVERSATIONS`, `BUSINESS_BOOKINGS`, `BUSINESS_FOOD_*` were 0 for all three). Syncing all 11 is fine (zeros are cheap) but the dashboard should hide all-zero series.
277
- - **Impressions skew to Maps for physical-destination businesses.** The hotel pulled 7,150 desktop-maps impressions vs 2,180 desktop-search in 30 days — people find it on Maps.
278
- - **Keyword thresholding scales with volume.** A busy hotel was ~89% thresholded (its head terms like `hotels`→10,412 had exact values); both small businesses were **100% thresholded** (every keyword redacted). For the typical SMB location, expect zero exact keyword values — design the UI to lead with the `<N` floor, not exact counts.
300
+ - **Impressions skew to Maps for physical-destination businesses.** The hotel pulled about three times as many desktop-maps impressions as desktop-search in 30 days (illustrative: 7,150 vs 2,180) — people find it on Maps.
301
+ - **Keyword thresholding scales with volume.** A busy hotel was ~89% thresholded (its head terms like `hotels`, at an illustrative 10,412, had exact values); both small businesses were **100% thresholded** (every keyword redacted). For the typical SMB location, expect zero exact keyword values — design the UI to lead with the `<N` floor, not exact counts.
279
302
  - **An empty Lodging resource is the norm, but it does NOT prove the owner set no amenities.** A real operating hotel returned a lodging resource with only `{ "name": ... }` and zero place-action links, yet its GBP "Hotel details" panel had amenities filled in (breakfast, wifi, parking, accessibility). So `populatedGroupCount === 0` means "canonry can't read structured attributes via this API response", not "the hotel has none". Surface the lodging signal as a **verify** (check the "Hotel details" panel), not a confirmed gap. The place-action emptiness is a separate, genuinely owner-readable signal.
280
303
  - **The Places cross-reference is a thin slice for hotels, not the full rendered listing.** Run live against the beachfront hotel at the Atmosphere tier, Place Details surfaced exactly one structured amenity, `wheelchair accessibility`, even though the rendered Google hotel module advertises far more (wifi, pool, room service, room rates). Those richer fields come from **Hotel Center**, which the Places API does not expose. So a thin or empty `gbp places` amenity list is NOT evidence the public listing is sparse; Places only carries a narrow, schema-bound subset (breakfast, dining, parking, pet-friendly, accessibility, restroom, family-friendly, outdoor seating, reservations). Read `gbp-listing-discrepancy` as a **floor** on the public-vs-owner gap (proof the listing advertises *at least* the named amenities), never a complete inventory. The owner-control point still stands at any size: even one amenity the profile fails to assert is a structured-data gap the operator can close.
281
304
 
@@ -290,7 +313,8 @@ Validated against three live businesses of different types (a computer-support s
290
313
  | Lodging endpoint returns 400 `FAILED_PRECONDITION` | Location primary category is not a lodging category | Update the primary category in the GBP UI to `Hotel`, `Resort`, `Motel`, etc. |
291
314
  | Lodging returns 200 with only `{ "name": ... }` | The GBP Lodging API returned no readable structured groups. In live testing this happened even when the "Hotel details" panel had amenities set | Not an error, and not proof of a gap. Surface as a verify: have the operator check the "Hotel details" panel; flag only if genuinely unset there |
292
315
  | Place action links empty | No CTAs configured | Set them up in the GBP UI; for many local businesses this is genuinely empty (an AEO gap) |
293
- | Reviews 403 `SERVICE_DISABLED` while v1 APIs work | Legacy v4 `mybusiness.googleapis.com` not enabled for this account/project | See "The legacy Google My Business API" above — enable via the approval-email shortcut as the approved account; can't be done via library or gcloud |
316
+ | Reviews 403 `SERVICE_DISABLED` while v1 APIs work (`gbp reviews` shows `unavailable (SERVICE_DISABLED)`) | Legacy v4 `mybusiness.googleapis.com` not enabled for this account/project | See "The legacy Google My Business API" above — enable via the approval-email shortcut as the approved account; can't be done via library or gcloud. Meanwhile, the Places fallback covers review alerts partially |
317
+ | No review alerts arrive | No webhook subscribes to `review.negative`, the location is still on its first (baseline) sync, or it has no review source | `cnry notify list <project>`, then `cnry doctor <project> --check gbp.reviews.access` for per-location coverage |
294
318
  | Q&A API unreachable | Google retired the Q&A API on 2025-11-03 | Permanent. Q&A is not available programmatically |
295
319
  | Keyword impressions mostly `threshold` instead of `value` | Low-volume keywords are privacy-redacted by Google | Expected — even a busy hotel can be ~89% thresholded; tiny businesses are 100%. Surfaced as `thresholdedKeywordPct` in the summary |
296
320
 
@@ -33,6 +33,7 @@ cnry google sync <project> --full --wait # full re-sync
33
33
  cnry google performance <project> # top rows by clicks
34
34
  cnry google performance <project> --days 90 --keyword "term"
35
35
  cnry google performance <project> --start 2026-06-01 --end 2026-06-30 --order-by date
36
+ cnry google query-totals <project> --window 30d # one row per named query (not a property total)
36
37
  ```
37
38
 
38
39
  ### Discover and inspect sitemaps
@@ -20,6 +20,36 @@ land. Server logs catch the upstream bot activity AND referrals at the
20
20
  edge — including arrivals GA4 missed because of cookie consent, ad
21
21
  blockers, or analytics gaps.
22
22
 
23
+ ## Reviewing suspicious referral bursts
24
+
25
+ Run `canonry traffic referral-assessment <project> --start-date YYYY-MM-DD
26
+ --end-date YYYY-MM-DD --format json`, or call MCP
27
+ `canonry_traffic_referral_assessment`. This reads stored evidence only.
28
+
29
+ The response preserves raw counts, separates redirects and subresources, and
30
+ flags candidate groups: one source, product, normalized path and UTC hour each.
31
+ A separate adjusted estimate subtracts those candidates from countable hits.
32
+ Legitimate peaks can qualify and small automated bursts can escape the rule.
33
+ `--burst-threshold` changes the default 100-hit review trigger; the default has
34
+ not been calibrated against live traffic distributions.
35
+
36
+ Server counts use short actor windows within ingestion batches, so they are
37
+ not GA sessions or verified people. Normalized paths can combine pages;
38
+ multiple sources can overlap. No Property, Target or market attribution is
39
+ available. These limits apply equally to Simple and Advanced portfolios.
40
+
41
+ The observed server/GA quotient, rounded to 2 decimals, uses
42
+ dimension-deduplicated GA sessions, but complete matching coverage and the GA
43
+ reporting timezone remain unknown. GA reads observed zero only when the window
44
+ lies inside the latest GA sync window and that sync stored no AI row for it;
45
+ otherwise absent GA rows are missing, never zero.
46
+ The silent `report.ai-referral-bursts` doctor advisory warns
47
+ (`report.ai-referral-bursts.bursts`) only when threshold-qualified bursts exist
48
+ to review; otherwise it passes (`no-bursts`) with these coverage limits in its
49
+ details. It skips a project whose traffic sources are all archived.
50
+ A high quotient does not confirm automation, and this diagnostic does not
51
+ replace GA or change any existing report headline.
52
+
23
53
  ## Architecture
24
54
 
25
55
  Four tables store the shared output from every adapter:
@@ -1 +1 @@
1
- import{r as a,u as h,j as e}from"./vendor-tanstack-ZXXkndVj.js";import{eH as L,eI as P,dI as E,j as u,B as x,f as v,T as M}from"./index-DwlO8KfE.js";const o=100;function k(s){if(s==="all")return;const n=Number.parseInt(s,10);return new Date(Date.now()-n*24*60*60*1e3).toISOString()}function O(s){const n=s.userAgent?.toLowerCase()??"";return n.includes("canonry-mcp")?"MCP":n.includes("canonry-cli")?"CLI":n.includes("mozilla/")?"Dashboard":s.actor==="scheduler"?"Scheduler":s.actor==="agent"?"Agent":s.actor==="mcp"?"MCP":s.actor==="cli"?"CLI":s.actor}function B(s){return/delete|remove|revoke|disconnect|failed/.test(s)?"negative":/create|add|connect|completed/.test(s)?"positive":/update|replace|sync|apply/.test(s)?"caution":"neutral"}function D(s){return[s.action,s.actor,s.entityType,s.entityId,s.actorSession,s.userAgent,s.diff==null?"":JSON.stringify(s.diff)].filter(Boolean).join(" ").toLowerCase()}function F({projectName:s}){const[n,l]=a.useState(0),[c,y]=a.useState("90d"),[m,N]=a.useState(""),[p,w]=a.useState(""),S=a.useMemo(()=>k(c),[c]),g={limit:o,offset:n*o,since:S,actor:m||void 0},C=h({...L({client:u,path:{name:s??""},query:g}),enabled:!!s}),A=h({...P({client:u,query:g}),enabled:!s}),f=h({...E({client:u}),enabled:!s}),i=s?C:A,r=i.data??[],I=a.useMemo(()=>new Map((f.data??[]).map(t=>[t.id,t.displayName||t.name])),[f.data]),d=p.trim().toLowerCase(),j=a.useMemo(()=>d?r.filter(t=>D(t).includes(d)):r,[r,d]),b=t=>{l(0),t()};return i.isLoading?e.jsx("p",{className:"supporting-copy mt-6",role:"status",children:"Loading change history…"}):e.jsxs("section",{className:"mt-6","aria-labelledby":"change-history-heading",children:[e.jsxs("div",{className:"section-head section-head-inline",children:[e.jsxs("div",{children:[e.jsx("p",{className:"eyebrow eyebrow-soft",children:"Audit trail"}),e.jsx("h2",{id:"change-history-heading",children:"Change history"}),e.jsx("p",{className:"supporting-copy mt-1 max-w-2xl",children:"Configuration and operator actions. Execution outcomes remain in Runs."})]}),e.jsx(x,{type:"button",variant:"outline",size:"sm",onClick:()=>{i.refetch()},disabled:i.isFetching,children:i.isFetching?"Refreshing…":"Refresh"})]}),e.jsxs("div",{className:"mt-4 grid gap-2 sm:grid-cols-3",children:[e.jsx("input",{type:"search","aria-label":"Search change history",value:p,onChange:t=>w(t.target.value),placeholder:"Search actions, entities, or details",className:"min-h-11 rounded-md border border-base bg-bg px-3 text-sm text-strong placeholder-mono-600 focus:outline-none focus-visible:ring-1 focus-visible:ring-mono-600"}),e.jsxs("select",{"aria-label":"Filter change history by actor",value:m,onChange:t=>b(()=>N(t.target.value)),className:"min-h-11 rounded-md border border-base bg-bg px-3 text-sm text-strong focus:outline-none focus-visible:ring-1 focus-visible:ring-mono-600",children:[e.jsx("option",{value:"",children:"All actors"}),e.jsx("option",{value:"api",children:"API and dashboard"}),e.jsx("option",{value:"cli",children:"CLI"}),e.jsx("option",{value:"scheduler",children:"Scheduler"}),e.jsx("option",{value:"agent",children:"Agent"}),e.jsx("option",{value:"mcp",children:"MCP"})]}),e.jsxs("select",{"aria-label":"Filter change history by date range",value:c,onChange:t=>b(()=>y(t.target.value)),className:"min-h-11 rounded-md border border-base bg-bg px-3 text-sm text-strong focus:outline-none focus-visible:ring-1 focus-visible:ring-mono-600",children:[e.jsx("option",{value:"30d",children:"Last 30 days"}),e.jsx("option",{value:"90d",children:"Last 90 days"}),e.jsx("option",{value:"365d",children:"Last year"}),e.jsx("option",{value:"all",children:"All retained history"})]})]}),i.isError?e.jsxs(v,{className:"surface-card empty-card mt-4",children:[e.jsx("h3",{children:"Change history unavailable"}),e.jsx("p",{children:i.error instanceof Error?i.error.message:"Could not load the audit trail."})]}):j.length===0?e.jsxs(v,{className:"surface-card empty-card mt-4",children:[e.jsx("h3",{children:"No changes match these filters"}),e.jsx("p",{children:"Widen the date range or clear the actor and search filters."})]}):e.jsx("div",{className:"evidence-table-wrap mt-4",children:e.jsxs("table",{className:"evidence-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),s?null:e.jsx("th",{children:"Scope"}),e.jsx("th",{children:"Action"}),e.jsx("th",{children:"Entity"}),e.jsx("th",{children:"Origin"}),e.jsx("th",{children:"Details"})]})}),e.jsx("tbody",{children:j.map(t=>e.jsxs("tr",{children:[e.jsx("td",{className:"whitespace-nowrap tabular-nums text-muted",children:new Date(t.createdAt).toLocaleString()}),s?null:e.jsx("td",{className:"text-secondary",children:t.projectId?I.get(t.projectId)??`Deleted project · ${t.projectId}`:"Instance settings"}),e.jsx("td",{children:e.jsx(M,{tone:B(t.action),children:t.action})}),e.jsxs("td",{children:[e.jsx("span",{className:"text-strong",children:t.entityType}),t.entityId?e.jsx("span",{className:"mt-0.5 block max-w-48 truncate font-mono text-[11px] text-faint",title:t.entityId,children:t.entityId}):null]}),e.jsxs("td",{children:[e.jsx("span",{className:"text-secondary",children:O(t)}),t.actorSession?e.jsxs("span",{className:"mt-0.5 block max-w-40 truncate font-mono text-[11px] text-faint",title:t.actorSession,children:["Session ",t.actorSession]}):null]}),e.jsx("td",{children:t.diff==null?e.jsx("span",{className:"text-faint",children:"No field diff"}):e.jsxs("details",{children:[e.jsx("summary",{className:"min-h-11 cursor-pointer py-3 text-sm text-secondary hover:text-strong",children:"View diff"}),e.jsx("pre",{className:"max-h-72 max-w-xl overflow-auto whitespace-pre-wrap break-words rounded-md bg-bg-elevated p-3 text-xs text-secondary",children:JSON.stringify(t.diff,null,2)})]})})]},t.id))})]})}),!i.isError&&(n>0||r.length===o)?e.jsxs("div",{className:"mt-4 flex items-center justify-between gap-3",children:[e.jsx(x,{type:"button",variant:"outline",size:"sm",disabled:n===0,onClick:()=>l(t=>Math.max(0,t-1)),children:"Previous"}),e.jsxs("span",{className:"text-xs tabular-nums text-muted",children:["Page ",n+1]}),e.jsx(x,{type:"button",variant:"outline",size:"sm",disabled:r.length<o,onClick:()=>l(t=>t+1),children:"Next"})]}):null]})}export{F as A};
1
+ import{r as a,u as h,j as e}from"./vendor-tanstack-ZXXkndVj.js";import{eH as L,eI as P,dI as E,j as u,B as x,f as v,T as M}from"./index-Ba-Zs45G.js";const o=100;function k(s){if(s==="all")return;const n=Number.parseInt(s,10);return new Date(Date.now()-n*24*60*60*1e3).toISOString()}function O(s){const n=s.userAgent?.toLowerCase()??"";return n.includes("canonry-mcp")?"MCP":n.includes("canonry-cli")?"CLI":n.includes("mozilla/")?"Dashboard":s.actor==="scheduler"?"Scheduler":s.actor==="agent"?"Agent":s.actor==="mcp"?"MCP":s.actor==="cli"?"CLI":s.actor}function B(s){return/delete|remove|revoke|disconnect|failed/.test(s)?"negative":/create|add|connect|completed/.test(s)?"positive":/update|replace|sync|apply/.test(s)?"caution":"neutral"}function D(s){return[s.action,s.actor,s.entityType,s.entityId,s.actorSession,s.userAgent,s.diff==null?"":JSON.stringify(s.diff)].filter(Boolean).join(" ").toLowerCase()}function F({projectName:s}){const[n,l]=a.useState(0),[c,y]=a.useState("90d"),[m,N]=a.useState(""),[p,w]=a.useState(""),S=a.useMemo(()=>k(c),[c]),g={limit:o,offset:n*o,since:S,actor:m||void 0},C=h({...L({client:u,path:{name:s??""},query:g}),enabled:!!s}),A=h({...P({client:u,query:g}),enabled:!s}),f=h({...E({client:u}),enabled:!s}),i=s?C:A,r=i.data??[],I=a.useMemo(()=>new Map((f.data??[]).map(t=>[t.id,t.displayName||t.name])),[f.data]),d=p.trim().toLowerCase(),j=a.useMemo(()=>d?r.filter(t=>D(t).includes(d)):r,[r,d]),b=t=>{l(0),t()};return i.isLoading?e.jsx("p",{className:"supporting-copy mt-6",role:"status",children:"Loading change history…"}):e.jsxs("section",{className:"mt-6","aria-labelledby":"change-history-heading",children:[e.jsxs("div",{className:"section-head section-head-inline",children:[e.jsxs("div",{children:[e.jsx("p",{className:"eyebrow eyebrow-soft",children:"Audit trail"}),e.jsx("h2",{id:"change-history-heading",children:"Change history"}),e.jsx("p",{className:"supporting-copy mt-1 max-w-2xl",children:"Configuration and operator actions. Execution outcomes remain in Runs."})]}),e.jsx(x,{type:"button",variant:"outline",size:"sm",onClick:()=>{i.refetch()},disabled:i.isFetching,children:i.isFetching?"Refreshing…":"Refresh"})]}),e.jsxs("div",{className:"mt-4 grid gap-2 sm:grid-cols-3",children:[e.jsx("input",{type:"search","aria-label":"Search change history",value:p,onChange:t=>w(t.target.value),placeholder:"Search actions, entities, or details",className:"min-h-11 rounded-md border border-base bg-bg px-3 text-sm text-strong placeholder-mono-600 focus:outline-none focus-visible:ring-1 focus-visible:ring-mono-600"}),e.jsxs("select",{"aria-label":"Filter change history by actor",value:m,onChange:t=>b(()=>N(t.target.value)),className:"min-h-11 rounded-md border border-base bg-bg px-3 text-sm text-strong focus:outline-none focus-visible:ring-1 focus-visible:ring-mono-600",children:[e.jsx("option",{value:"",children:"All actors"}),e.jsx("option",{value:"api",children:"API and dashboard"}),e.jsx("option",{value:"cli",children:"CLI"}),e.jsx("option",{value:"scheduler",children:"Scheduler"}),e.jsx("option",{value:"agent",children:"Agent"}),e.jsx("option",{value:"mcp",children:"MCP"})]}),e.jsxs("select",{"aria-label":"Filter change history by date range",value:c,onChange:t=>b(()=>y(t.target.value)),className:"min-h-11 rounded-md border border-base bg-bg px-3 text-sm text-strong focus:outline-none focus-visible:ring-1 focus-visible:ring-mono-600",children:[e.jsx("option",{value:"30d",children:"Last 30 days"}),e.jsx("option",{value:"90d",children:"Last 90 days"}),e.jsx("option",{value:"365d",children:"Last year"}),e.jsx("option",{value:"all",children:"All retained history"})]})]}),i.isError?e.jsxs(v,{className:"surface-card empty-card mt-4",children:[e.jsx("h3",{children:"Change history unavailable"}),e.jsx("p",{children:i.error instanceof Error?i.error.message:"Could not load the audit trail."})]}):j.length===0?e.jsxs(v,{className:"surface-card empty-card mt-4",children:[e.jsx("h3",{children:"No changes match these filters"}),e.jsx("p",{children:"Widen the date range or clear the actor and search filters."})]}):e.jsx("div",{className:"evidence-table-wrap mt-4",children:e.jsxs("table",{className:"evidence-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{children:"When"}),s?null:e.jsx("th",{children:"Scope"}),e.jsx("th",{children:"Action"}),e.jsx("th",{children:"Entity"}),e.jsx("th",{children:"Origin"}),e.jsx("th",{children:"Details"})]})}),e.jsx("tbody",{children:j.map(t=>e.jsxs("tr",{children:[e.jsx("td",{className:"whitespace-nowrap tabular-nums text-muted",children:new Date(t.createdAt).toLocaleString()}),s?null:e.jsx("td",{className:"text-secondary",children:t.projectId?I.get(t.projectId)??`Deleted project · ${t.projectId}`:"Instance settings"}),e.jsx("td",{children:e.jsx(M,{tone:B(t.action),children:t.action})}),e.jsxs("td",{children:[e.jsx("span",{className:"text-strong",children:t.entityType}),t.entityId?e.jsx("span",{className:"mt-0.5 block max-w-48 truncate font-mono text-[11px] text-faint",title:t.entityId,children:t.entityId}):null]}),e.jsxs("td",{children:[e.jsx("span",{className:"text-secondary",children:O(t)}),t.actorSession?e.jsxs("span",{className:"mt-0.5 block max-w-40 truncate font-mono text-[11px] text-faint",title:t.actorSession,children:["Session ",t.actorSession]}):null]}),e.jsx("td",{children:t.diff==null?e.jsx("span",{className:"text-faint",children:"No field diff"}):e.jsxs("details",{children:[e.jsx("summary",{className:"min-h-11 cursor-pointer py-3 text-sm text-secondary hover:text-strong",children:"View diff"}),e.jsx("pre",{className:"max-h-72 max-w-xl overflow-auto whitespace-pre-wrap break-words rounded-md bg-bg-elevated p-3 text-xs text-secondary",children:JSON.stringify(t.diff,null,2)})]})})]},t.id))})]})}),!i.isError&&(n>0||r.length===o)?e.jsxs("div",{className:"mt-4 flex items-center justify-between gap-3",children:[e.jsx(x,{type:"button",variant:"outline",size:"sm",disabled:n===0,onClick:()=>l(t=>Math.max(0,t-1)),children:"Previous"}),e.jsxs("span",{className:"text-xs tabular-nums text-muted",children:["Page ",n+1]}),e.jsx(x,{type:"button",variant:"outline",size:"sm",disabled:r.length<o,onClick:()=>l(t=>t+1),children:"Next"})]}):null]})}export{F as A};