AGENTS.md@packages/api-routes/src/doctor · git:20261008.27c13aa · 2026-10-08 · sha256 b6c5b4c8482c0cf4
AGENTS.md@packages/api-routes/src/doctor git:20261008.27c13aaA
Immutable. This exact content never changes and is served at /api/v1/blob/b6c5b4c8482c0cf4.
# doctor `canonry doctor` runs an extensible set of health checks across global config and project-scoped integrations. Each check has a stable dotted ID (`google.auth.connection`, `ga.auth.connection`, `config.providers`, …) so an agent or skill can filter via `--check <id>` / `?check=<id>` and react to specific failures programmatically. - **CLI:** `canonry doctor [--project <name>] [--check <id>...] [--format json]` - **API:** `GET /api/v1/doctor` (global), `GET /api/v1/projects/:name/doctor` (project-scoped). Both accept `?check=<comma-separated ids or wildcards>`. - **MCP:** `canonry_doctor` (core tier) — passes `project` + `checks[]` straight through. Each check returns `status: ok | warn | fail | skipped`, a stable machine-readable `code`, a `summary`, optional `remediation`, and structured `details`. v1 ships: | Category | ID | Scope | Purpose | |----------|----|-------|---------| | database | `db.file.present` | global | Configured SQLite database file still exists on disk (catches `rm ~/.canonry/data.db` against a running daemon — SQLite holds the inode open across `unlink`) | | config | `config.file.present` | global | Configured `~/.canonry/config.yaml` still exists on disk (same gotcha as above) | | config | `canonry.version.current` | global | The running server is the newest published `@canonry/canonry`: warns `version.outdated` with an upgrade command for the detected install (npm, Homebrew, or container) plus a restart reminder; skipped when the update check is opted out (reports which opt-out, never suggests undoing it), before the registry has been reached, or on deployments that don't report update status. Registry values that are not strict semver are ignored | | auth | `google.auth.connection` | project | OAuth credentials present, refresh token works | | auth | `google.auth.property-access` | project | Authorized principal can list the selected GSC site | | auth | `google.auth.redirect-uri` | project | `publicUrl`-derived redirect URI is valid + advertised | | auth | `google.auth.scopes` | project | Granted GSC + Indexing scopes match what's stored | | auth | `ga.auth.connection` | project | GA4 service account verifies against the configured property | | auth | `gbp.auth.connection` | project | Google Business Profile OAuth credentials present, refresh token works | | auth | `gbp.auth.scopes` | project | Granted scope includes `business.manage` | | auth | `gbp.account.access` | project | The tracked GBP account is still listable for the authorized user (maps 0-QPM access-form-pending → warn) | | auth | `gbp.places.api-key` | project | Google Places API readiness for the listing cross-reference (#648): warns when GBP is connected but no Places key is set, or when no selected location carries a Maps place id; skipped when Places is disabled (`tier: off`) or GBP isn't connected | | integrations | `gbp.reviews.access` | project | Whether negative-review alerts can fire, per selected location, read from what the last sync stored (no Google call). **ok** when v4 reviews work everywhere (a transient v4 error where v4 has worked before still counts). **skipped** when v4 is gated (the normal state) and locations use the Places fallback or have no source. **warn** only when the project subscribes to a review event and some location has no source, since health alerts reach every webhook | | integrations | `gbp.data.recent-sync` | project | A selected GBP location synced in the last 4d (warn) or 30d (fail); warns when never synced. 4d rather than 7d because GBP metrics land daily, so a week of silence is already a long outage | | integrations | `ga.data.recent-data` | project | Newest stored GA4 daily row is no older than 3d (`ga.data.aging`) or 5d (`ga.data.stale`), both **warn** so a failing auth check keeps the headline. Catches a sync that keeps succeeding with zero rows, for example a GA4 tag removed from the site. Reports `ga.data.not-syncing` instead when no sync has completed recently; skipped when GA4 is not connected | | integrations | `gsc.data.recent-data` | project | Same for Search Console at 5d and 7d, graded from the monotonic `gsc_data_watermarks` date (which advances even on zero-impression days) against Google's Pacific reporting date; skipped when GSC is not connected | | integrations | `site.reachability` | project | **Opt-in** (`optIn: true`): runs only when a filter names it, so an unfiltered doctor pass never reaches the network. The project homepage answers below HTTP 500, retried once, trying every approved address. Each hop is resolved and checked against private, reserved and link-local ranges before dialing. A 403 or 429 counts as up; a name that resolves only to refused addresses fails as `site.reachability.refused-address`; this host's own resolver failing is `skipped`, never an outage | | integrations | `google-ads.sync.recent-failures`, `gtm.sync.recent-failures`, `gbp.sync.recent-failures`, `ga.sync.recent-failures`, `gsc.sync.recent-failures` | project | Repeated sync failures, read from the integration's run rows (no provider call). **fail** (`<integration>.sync.repeated-failures`) when the newest 3 finished runs (`completed`, `partial`, `failed`; queued, running and cancelled runs are ignored) all failed, with the latest run error, the failure streak since the last success, and the last success time (or `never`); **ok** otherwise; **skipped** when the integration is not connected (so a disconnected integration's old failures stay quiet), when GBP is connected but the project selects no location (`gbp.sync.no-selected-locations`, the state `gbp.data.recent-sync` also skips, where every sync fails by design), or when no run has finished. **skipped** as `<id>.superseded` (original code and status kept in `details`) when one of the integration's auth checks fails in the same pass, so the check that names the fix keeps the health alert's headline instead of the integrations-ranked symptom: `google-ads.auth.connection`, `google-ads.auth.scopes`, `google-ads.account.context`; `gtm.auth.connection`, `gtm.auth.scopes`, `gtm.container.context`; `gbp.auth.connection`, `gbp.auth.scopes`, `gbp.account.access`; `ga.auth.connection`; `google.auth.connection`, `google.auth.property-access`, `google.auth.scopes` (`supersededBy` on the definition, applied by the runner). A cause that threw (`<cause>.runtime-error`) or got no answer from Google (a code ending in `-unreachable`) tested nothing and supersedes nothing: a DNS block that fails every sync fails the auth check's live token refresh too, and the sync's stored error is what names it. GA4 grades only runs created since its current connection (service account first, then OAuth), because its sync writes the run row before resolving credentials and so leaves a failed row on every GA-less project each data refresh; the streak and the last success are both counted from then (`details.gradedSince`), and a connection with no success yet reads `none since connecting at <time>`. GBP leaves out runs that failed with `GBP_NO_SELECTED_LOCATIONS_ERROR` (contracts), matched as the sync stores it, because `gbp_locations` does not record when a location was selected and those runs had nothing to sync. A `partial` run stored data, so it ends a streak. Exists because a queued sync answers its endpoint before any provider call: the scheduled data refresh logs `integration.queued`, and the failure lands only on the run row | | auth | `ads.auth.connection` | project | OpenAI ads connection row has a matching SDK key in the local config (skipped when not connected) | | integrations | `ads.data.recent-sync` | project | Connected ad account synced in the last 7d (warn) or 30d (fail); warns when never synced (skipped when not connected) | | auth | `wordpress.publish.connection` | project | WordPress publishing connection (`integration-wordpress`): the Application Password authenticates and the `wp/v2` REST API responds; skipped when no connection is configured | | auth | `traffic.source.credentials` | project | Per-source-type credential validation (Cloud Run service-account access token resolves; WordPress and Vercel probe-call their endpoints) | | auth | `traffic.source.scopes` | project | Per-source-type scope validation (skipped where the adapter has no explicit scope check — e.g. WordPress Application Passwords, Vercel API tokens) | | schedules | `report.sweeps` | project | **Silent** report advisory: whole-project sweeps of the report month together answered every expected slot (see "Monthly report readiness") | | providers | `report.models` | project | **Silent** report advisory: each provider kept one known model across the report month and the month before, in every frame the monthly comparison gates on | | integrations | `report.daily-data` | project | **Silent** report advisory: every mature GA4 and Search Console date of the report month has a stored total or was proven quiet by the latest sync | | integrations | `traffic.source.connected` | project | At least one non-archived server-side traffic source exists for the project | | integrations | `traffic.source.recent-data` | project | Connected sources have crawler, AI user-fetch, or AI-referral events in the last 7d (warn) or 30d (fail) | | integrations | `traffic.source.sync-lag` | project | Pull-source watermark health. Skips only Cloudflare `deliveryMode=direct-push` (legacy missing mode is direct push); Queue pull remains checked. | | integrations | `traffic.source.worker-version` | project | Cloudflare direct/Queue last-observed Worker health. Warns before the first ingested batch and when the most recently ingested version differs from the current generated version. | | integrations | `report.ai-referral-bursts` | project | **Silent** report advisory (see "Referral reporting diagnostic"): warns only when stored AI referrals hold threshold-qualified burst groups to review before quoting server-side AI referral totals; skipped when every traffic source is archived | | integrations | `backlinks.source.connected` | project | Common Crawl is ready (`autoExtractBacklinks` + a `ready` release sync); warns when it is not set up | | integrations | `content.winnability.coverage` | project | Discovery classification coverage for cited-surface domains behind the content winnability gate; warns when discovery has not classified the domains that make ownable/ceded decisions meaningful | | providers | `config.providers` | global | At least one answer-engine provider key configured | | providers | `config.agent-providers` | global | At least one agent LLM provider (claude / openai / gemini / zai) has a usable key — warns when none do (the built-in Aero agent can't run); skipped on deployments that don't run the agent. Reports `agent-providers.restricted`, with no counts or details, to a caller who is not an install administrator: which provider drives the agent is administrator knowledge, and `GET /doctor` has no administrator gate of its own | | agent | `agent.skills.installed` | global | Both bundled skills (`canonry`, `aero`) are available through a verified native plugin cache or present under `~/.claude/skills/`; an enabled entry with missing/corrupt assets warns instead of reporting a false success | | agent | `agent.skills.trigger-surface` | global | The bundled skills' `description` frontmatter, which is their ENTIRE trigger surface: a skill is model-decided, so nothing forces it to load and the description is the only text a request is matched against. Fails on a missing description or one over the 1024-char spec cap; warns when one is too thin to match or never names the CLI binary the operator actually types. Reports total listing cost, which competes for the host's per-session skill budget. Measures the surface, never the outcome. | | agent | `agent.skills.current` | global | Native plugin manifest versions must match the running Canonry bundle; version mismatches warn. Legacy `~/.claude/skills/` trees are compared file-by-file and warn when new or upstream-updated files have not been picked up (local edits do not count as "behind") | ## Check implementation notes `src/doctor/checks/*.ts` (individual `CheckDefinition`s): - `content` covers `content.winnability.coverage`, which measures how many cited-surface domains the shared surface classifier recognizes (own / tracked-competitor / static allow-list / stored discovery `domain_classifications`) so the ownable/ceded gate does not silently fail open, and nudges to set an ICP when the project has none. - `gbp-auth` covers `gbp.auth.connection` / `gbp.auth.scopes` / `gbp.account.access` (token refresh + `business.manage` scope + the tracked account is listable via `listAccounts`, mapping `GbpApiError` 0-QPM → a `quota-pending` warn) and `gbp.data.recent-sync` (selected-location sync freshness). - A live provider call that got no answer (`isFetchTransportError` from contracts: DNS, connect, reset, TLS, timeout) fails with a code ending in `-unreachable` (`UNREACHABLE_CODE_SUFFIX` in `types.ts`), never the code for a rejection, and names the host through `describeFetchError`: `google.auth.refresh-unreachable`, `google.auth.list-sites-unreachable`, `gbp.auth.refresh-unreachable`, `gbp.account.list-unreachable`, `ga.auth.refresh-unreachable`, `ga.auth.verify-unreachable`. The credential was not tested, so the remediation never says to re-authorize, and the runner never lets such a result supersede a symptom. - The `traffic-source` checks are adapter-agnostic — they query `traffic_sources` directly for connection/recent-data, and dispatch credential / scope validation through `DoctorContext.trafficSourceValidators[<sourceType>]`. - v1 registers validators for `cloud-run` (service-account-token resolution), `wordpress` (probe-call against the plugin's REST endpoint), and `vercel` (probe-call against the `request-logs` endpoint — 401/403 maps to `traffic.credentials.unauthorized`), wired from the corresponding credential stores in `index.ts`. Future adapters plug in by adding a key to that map — no doctor-side changes needed. - Cloudflare sources follow "Cloudflare traffic doctor boundary" above. ## Referral reporting diagnostic `report.ai-referral-bursts` is a DB-only, silent project check. It follows the report-month selection and calls the same assessment reader as the API/CLI/MCP. It skips (`report.ai-referral-bursts.not-configured`) when the project has no non-archived traffic source, the rule `traffic.source.connected` and traffic analytics use. Per month, its details carry the `suspected` burst hits, `candidateGroups` (threshold-qualified source × product × normalized path × UTC hour groups, not hours), the rule, and the comparison: the countable server total (`serverCountable`, not raw hits), dimension-deduplicated GA sessions, an observed quotient rounded to 2 decimals, and explicit missing/observed-zero states. GA is observed-zero only for a window inside the latest GA sync window that holds no AI row from an earlier sync (`latestGaSyncQueriedWindow` in `src/referral-assessment.ts`). Current records cannot prove complete server intervals or the GA reporting timezone, so unproven coverage is never the signal: it warns (`report.ai-referral-bursts.bursts`) only when a report month has threshold-qualified bursts to review, and otherwise passes (`report.ai-referral-bursts.no-bursts`). A high quotient is not a comparable-window warning or proof of automation. Silent checks never change health paging state. ## Scheduled health alerts Two schedules feed `health.degraded` and `health.recovered`, which reach every enabled webhook whether or not it subscribes to them: - `doctor` (every 6h, seeded per project) grades every check and notifies when the worst `(status, code)` changes, **or when the set of failing checks changes** (`doctor_health_state.failing_signature`, sorted `status:code` pairs). The second rule exists because equally severe checks are ranked by id: a breach opening under an alphabetically earlier one left the headline code untouched, so it was graded, listed in the payload's `failing`, and then dropped at the trigger. A row predating that column carries NULL, which reads as unknown rather than changed, so shipping the rule pages nobody on its first pass. `site.reachability` is `optIn`, so it never runs in that pass. - The website loop (every 10 min, in-process, started with the server) runs only `site.reachability` and keeps its own `site_liveness_state` row. It pages after two failed passes that are genuinely an interval apart, and sends `health.recovered` only for an outage it actually delivered a page for. It never writes `doctor_health_state`, so a quick "site is up" pass cannot clear a GA outage. It is deliberately not a schedule row: an older build would not recognize the kind and would run the row as a paid answer-visibility sweep after a rollback. ## Adding a new check 1. Implement a `CheckDefinition` in `packages/api-routes/src/doctor/checks/<topic>.ts`. Use `@ainyc/canonry-contracts` `CheckStatuses` / `CheckCategories` / `CheckScopes` enums — never raw strings. A check that reports a symptom of another check's failure (a sync failing because its credential is revoked) lists that check's id in `supersededBy`, so the cause leads the health alert. A cause check whose provider call got no answer reports a code ending in `-unreachable`, so it never hides the symptom's evidence. 2. Register it in `packages/api-routes/src/doctor/registry.ts` (`ALL_CHECKS`). 3. Add a `<topic>.ts` test under `packages/api-routes/test/doctor-*` covering the happy path + each `code` value the check can emit. 4. Both the CLI and MCP tool surface the new check automatically — no additional wiring required. ## Monthly report readiness `report.sweeps`, `report.models`, `report.daily-data`, and `report.ai-referral-bursts` are stored-evidence project checks. They run by default and carry `notificationPolicy: silent`. The notifier excludes silent checks from health status, signatures and recovery; a report-only pass must leave existing operational state untouched. The scheduled health pass requests only `scheduledHealthCheckIds()` (non-silent, non-opt-in), so it never pays for advisories the notifier would discard. A silent check that throws reports `warn`, never `fail`: an advisory cannot fail `canonry doctor`. `reportMonth=YYYY-MM` selects a report month (never a future month). Omitted, checks cover the current UTC month and retain the previous closed month through day 3. This is read selection, not a work-identity or tuning parameter. While both months are shown (days 1-3), only the closed month decides a check's status (`gradedReportMonths`); the new month is listed with `graded: false`. Sweep readiness excludes probes and spot checks: a plan run with a `measurementScope`, and a Simple run with a `queries` subset (the same rule as `newerFullSweep`). Plan runs (schema v1 or v2) validate their manifest against the frozen revision and match usable answers by execution slot (`measurementSlotKey`); an answer with no execution id makes the run unusable. Simple runs use the frozen input definition when present; legacy runs explicitly report current-basket coverage as their basis (an empty project provider list means every configured provider, which only the runner knows, so such a run is held to the providers it answered on). A month is ready when its sweeps with the same expected slots together answer every slot, as the monthly comparison pools a month; a failed or empty run cannot clear readiness. Only the widest expectations decide: a group whose slots are a strict subset of another group's (a `canonry run --provider` run) cannot clear the month. Runs do not record a provider override, so a month whose only runs used one reads as that narrower set. Model checks read `readVisibilityContinuity`: the same matched-pair continuity gates as monthly comparison, including unknown and mixed models, without matching answer text. Every project gets the project frame, which gates the compare's four project metrics; an Advanced project whose two months are frozen schema-v2 history also gets the class frame, which gates the class rates. A schema-v1 plan has no class frame (its class rates are unavailable), so it never warns for lack of one. First observed dates do not claim to be provider deployment dates. Daily checks distinguish observed totals (including measured zero), dates the latest sync queried that returned no row, unknown dates, onboarding dates and pending reporting dates. Both APIs omit zero-data rows, and each sync replaces its whole requested range, so an absent date inside the latest sync's range (`latestSyncRange`: the GA summary window, or the Search Console rows' span up to the watermark's requested ceiling when both came from that sync) is zero activity, but only up to the reporting lag before that sync ran (`zeroProvenThrough`): a sync asks through its own day, and the source had not published its last few days yet. Earlier syncs' ranges are not recorded: absent dates outside the proven range stay unknown, never confirmed gaps or synthetic zeros. Search Console uses Pacific dates. GA timezone is unrecorded and the response labels its UTC fallback. Both use a conservative three-day reporting lag. The scheduled GA refresh reaches back to the first day of the closed month through day 3 (`gaRefreshDays`), so the report month sits inside the latest GA sync. Backfill is advice only.