Directory map

August 13, 2026 · View on GitHub

This page is the authoritative map. Each line is path # why it exists + the behaviours and #-issues that shaped it — the annotation and the path are the same line, so they cannot be split apart. CLAUDE.md carries a lean mirror of the same tree (path → one short purpose) so that file stays near its ~40k guideline.

Two copies of the tree therefore exist, by design. When you add, rename, move, or repurpose a module, update both: the annotated line here, and the mirror line in CLAUDE.md's ### Directory Layout. If they disagree, this page wins.

api/
  intro.ts          # Landing page Edge Function (/intro) — dashboard mock; ?banner=<key> announcement slot
  _intro/
    html-template.ts # SSR HTML template (i18n, dashboard mock, GA4)
    announcements.ts # Reusable campaign-banner config + resolver (?banner=<key>, #265) — empty by default
  is-down.ts        # "Is X Down?" Edge Function (excludes bedrock/azureopenai per #263)
  _is-down/
    html-template.ts # SSR HTML template for /is-{slug}-down — the page body AND the social-card meta. Home of the og-pin lineage: the card is pinned to the SHARE MOMENT rather than live status, because a platform unfurls the link minutes-to-hours after it was posted. `?e=` pins the card STATUS (`HINT_TO_OG_STATUS`; `reddit`/unknown/absent → live status — and the lookup is a `hasOwnProperty.call`, so an inherited key like `?e=constructor` is not a pin); `?e=` + the #804 `&i=` incident token also form `og:url`, which is the identity platforms cache and dedupe cards by (NOT the fetched URL) — hence one identity per share moment per outage. **#1103**: the pin stopped at the og:url layer — a wall-clock 10-min cache-bust `v=` was appended to `og:image` unconditionally, so the image URL beneath a per-share-frozen og:url still moved. `v` is now emitted only when og:url is NOT pinned (`ogUrlPinned` — a RECOGNIZED `?e=` or an `?i=` token; `?e=reddit` and unknown hints are not pins, so those keep the buster). Why a pinned share needs none, and the `score`/`uptime` residual that still moves it, are at the `v` line in the code. The alert-side half of the lineage (which link gets which hint) is in the `worker/src/alerts.ts` entry below. (Other helpers in this dir — share-url, region-status, seo-content, slug-map, supply-chain-note, incident-grouping/sort, ranking — have no entry yet; this list is not exhaustive)
    upstream-note.ts # #1053 — the "Related Upstream Incident" block in the is-down PAGE BODY (not the social card): renders the dependent's OWN published attribution naming a provider that is itself down, never an AIWatch-asserted cause
  reports.ts        # Monthly Reports proxy (/reports/* → bentleypark.github.io/aiwatch-reports/*, fetched directly to bypass the Cloudflare 301 on the public reports.ai-watch.dev hostname) with HTML path rewriting (#264)
  methodology.ts    # Public "How AIWatch Works" methodology page Edge Function (/methodology, #673) — self-contained SSR, no data fetch
  _methodology/
    html-template.ts # SSR HTML template (KO/EN i18n, doc-style sticky-TOC scroll-spy, 7 sections) — CSP-clean (no inline handlers); absorbed the retired in-dashboard AboutScore page (#about-score now redirects to /methodology#score)
  plugin.ts         # Public AIWatch Claude Code plugin landing (/plugin, #920) — self-contained Edge SSR, no data fetch. SEO-crawlable HTML at ZERO Serverless-Function cost (edge runtime is exempt from the 12-fn cap #862 — the reason a real page beats a SPA hash route here). Nonce CSP (no-store) like methodology
  _plugin/
    html-template.ts # SSR landing (EN + KO notice): what the plugin does (monitor + /aiwatch) + ungated install (api/_shared/plugin-cta.ts — commands always render, serving our own `aiwatch-dev` catalog; `PLUGIN_MARKETPLACE_URL` is now additive-only: empty until claude-community approval, and setting it merely APPENDS a listing link, unlike the #888-style extension-cta gate it originally mirrored) + SoftwareApplication JSON-LD + a cross-link to /#statusline + Full-privacy-policy link → /plugin-privacy. CSP-clean (delegated [data-ga] listener)
  plugin-privacy.ts # Public plugin privacy policy (/plugin-privacy, #920) — self-contained Edge SSR mirroring extension-privacy.ts. The claude-community marketplace privacy-policy URL; SEPARATE from the site policy (LegalContent.jsx) AND the extension policy: the plugin uses no cookies/analytics SDK + reads no code (only polls the public status API + anonymous aggregate counting). No inline script → CSP-clean + edge-cacheable. Facts pinned against plugin/aiwatch/README.md by plugin-privacy.test.ts
  confirm.ts        # Per-user Discord webhook double-opt-in confirmation page (#486) — the channel-control opt-in the cron requires before it will deliver to a user webhook. Edge runtime
  extension-privacy.ts # Chrome-extension privacy policy (#837) — the Web Store listing's policy URL. SEPARATE from the site policy (LegalContent.jsx) and the plugin policy (plugin-privacy.ts); the extension reads no page content (host_permissions = worker origin only). Edge runtime
  csp-report.ts     # CSP violation sink (#482) — report-uri target; violations land in Vercel logs. Edge runtime
  badges.ts         # Public "AI Status Badges" gallery Edge Function (/badges, #805 Problem B) — self-contained SSR, no data fetch
  _badges/
    html-template.ts # SSR gallery: every service's live /badge/:id + copy-markdown linking to the crawlable /is-{slug}-down page (badgeMarkdownFor), grouped by the display taxonomy (GROUP_ORDER mirrors is-down FOOTER_CATEGORY_ORDER). The one canonical "grab a badge" destination + an SEO page; copy_badge GA4 event (location:badges_page)
  _shared/
    audience-beacon.ts # The #842-B consent-free outage-audience pageview beacon, as ONE primitive both is-down surfaces inline (#1193). It used to live inside `_is-down/html-template.ts` and therefore fired only on PER-SERVICE pages — which became a measurement hole, not just a coverage gap, once the operator Reddit block started handing out the provider GROUP link for a family-wide incident: those visits landed on `/is-<family>-down` and posted nothing, so a Reddit visitor arriving on a family link was invisible to `audienceBySource`. `svc` must be a real SERVICE id (`parsePageviewBody` validates against SERVICES and drops the row otherwise — a silent zero, not an error), so a family page passes one of its member ids (`api/_is-down/__tests__/family-groups.test.ts` pins that every member resolves). Both callers hash their own page CSP over the rendered HTML, so there is nothing to register centrally. (This dir also holds csp-hash, consent-init, cookie-banner, extension-cta, plugin-cta and others with no entry yet — this list is not exhaustive)
src/
  components/   # Shared UI: StatusPill, SkeletonUI, EmptyState, Modal, Sidebar, Topbar, CookieBanner, AnalysisModal
  pages/        # Overview, Latency, Incidents, Uptime, ServiceDetails, Settings, Ranking, Statusline
  hooks/        # usePolling, useTheme, useLang, useSettings, useGitHubStars
  utils/        # analytics, calendar, time, pageContext, constants, hashRoute (hash→page routing + #about-score→/methodology#score redirect, #673), webhookSubscription (client for the server-side per-user Discord subscription endpoints, #486), liveIncident (#1104 — `hasLiveIncident` / `readsResolved` / `showRecoveredChip`: the single rule behind every SPA claim that a service is resolved — the AnalysisModal "Resolved" pill and the "Recently Resolved" chip on BOTH the Overview card and the ServiceDetails header. Exists because the worker now keeps an incident whose impact on our component ENDED while the incident stays open, so an operational badge no longer implies the incident is over, and three surfaces each deriving that from the badge answered differently. `monitoring` is deliberately NOT live — the provider is confirming recovery, the same cut `/api/status/cached` makes when picking which analyses to send. The Edge is-down template carries its own `hasLiveIncident` because the two bundles share no module; they are pinned against drift by `api/_is-down/__tests__/liveIncident-sync.test.ts`, and the SPA call sites by `src/components/__tests__/live-incident-wiring.test.js` — a green pure function is not a green call site. Overview's per-incident "Recently Resolved" BANNER is deliberately outside the rule: it names the incident it resolved, so it stays true while a sibling is open)
  locales/      # ko.js, en.js — flat key→string maps (default exports)
docs/
  reference/    # OKF knowledge bundle — read index.md first; lint:okf gates its structure
scripts/
  generate-og-intro.mjs # OG intro image generator (uses icon-192.png + sharp)
  check-edge-e2e-coverage.mjs # CI guard (#1051), TWO checks because #1051 had two halves: (a) COVERAGE — every user-facing Edge SSR page under api/ is referenced by the CODE of a spec an EDGE project actually runs (a `desktop` spec asserting `a[href="/badges"]` mentions the path but executes against Vite :5173, where it 404s — counting it would disarm the guard for exactly the pages #1051 was about, and Sidebar.jsx links three of them); (b) WIRING — every `use: EDGE_USE` project appears in package.json `test:edge` AND its testMatch appears in the `desktop` project's testIgnore. (b) is the half that actually shipped the bug: the is-down/intro specs existed and ran, the other six pages were simply absent from the project list — so a coverage-only grep would bless a `tests/newpage.spec.js` no project executes. Classifies Edge projects by IMPORTING the config and comparing `use.storageState` to EDGE_BYPASS_STATE — a text parse was the first attempt and was fail-open four ways (spread `use`, `use:` before `name:`, `--project=edge-pages-v2` satisfying `edge-pages`, and grabbing the first `testIgnore` without checking it was desktop's); each reported WIRED when it was not, the very shape of the bug. Importing is free: the config is a plain object and `globalSetup` is a string literal defineConfig never resolves. Closes the gap that let #920 ship — `/plugin` published an install command resolving for nobody for MONTHS while "Edge E2E (Vercel Preview) pass" stayed green, because `test:edge` ran only is-down/intro/reports/consent and never loaded the changed page (6 of 9 pages had zero e2e). Coverage-only by design (does any spec reference the path — not "is the assertion good"): a page reached by a bad test is a review problem, a page reached by NO test is invisible. Matching runs over `stripTestTitles(stripComments(src))` — reusing check-e2e-ga-guard's tokenizer rather than copying it (#1006) — because a header comment or a `test.describe('/plugin …')` title would otherwise satisfy a raw grep while the PAGES table that does the navigating sat empty (verified: with the strip, deleting /plugin from the table fails; without it, the guard reported green). Boundary-aware so `/plugin-privacy` cannot cover `/plugin`. New pages default to needing `/<stem>`; `PAGE_PATH_OVERRIDES` holds the irregulars as a RegExp shape rather than a loose substring (`is-down` → `/\/is-[a-z0-9-]+-down/`, served as `/is-{slug}-down` via vercel.json rewrites; a bare `-down` token would be satisfied by any `scroll-down` in any spec), `NON_PAGE_ENDPOINTS` the exemptions WITH a reason (`csp-report` = POST-only sink). Pure fns + a real-tree assertion via `npm run test:scripts`
tests/
  edge-pages.spec.js  # (#1051) The `edge-pages` Playwright project — the Edge SSR pages that had no e2e: plugin/methodology/badges/plugin-privacy/extension-privacy + the confirm 400 path. One project + a PAGES table rather than five near-identical projects: they share one contract (200 + title + canonical + robots + CSP header). Status/header are asserted BEFORE the DOM so the Deployment-Protection wall (401 to anonymous requests per edge-e2e.yml) or a 500 fails loudly instead of as an empty-locator error; `maxRedirects: 0` so a redirect to SSO is caught rather than followed. `/plugin` additionally pins the DEPLOYED page against the imported `PLUGIN_MARKETPLACE_ADD`/`PLUGIN_INSTALL_CMD` constants (never re-typed), completing the chain page → constants → shipped catalog (plugin-page.test.ts pins the other half). `/confirm`'s happy path needs the worker signing secret so only the bad-token 400 is reachable here. **Adding an Edge spec means THREE edits, not two**: its project, the `desktop` project's `testIgnore` (desktop points at Vite :5173, where these paths 404), AND `--project=<name>` in package.json `test:edge` — the third is the one #1051 actually missed. All three are enforced by check-edge-e2e-coverage.mjs
worker/
  src/
    index.ts    # Worker entry: CORS, KV, routing, /api/alert, /badge, /api/v1, /api/internal/deepseek-feed (#618), Cron scheduled handler
    deepseek-dispatch.ts # #629 — Worker */5 cron workflow_dispatches the deepseek-feed Action (GitHub's own schedule is throttled to ~2h). See parsers/flashduty.ts + docs/reference/data-flow.md
    services.ts # Service configs + fetch orchestrator + status determination (resolveSvcStatus worst-of badge; resolveSvcComponents = #604 per-component snapshot → ServiceStatus.components, source = displayAllComponents → displayComponentIds → statusComponentIds, ≥2-gated, badge-decoupled. #606: displayComponentIds = curated list (elevenlabs/replicate/assemblyai/deepgram/characterai/junie/voyageai/pinecone; #761 also Instatus Next.js fal/perplexity via parseInstatusComponents — top-level components, Nuxt/mistral deferred); displayAllComponents = dynamic all-minus-componentDenylist with componentSurfaces individual + rest group:'Models' collapsed (cohere/groq); Cat B splits shared status.openai.com across openai/chatgpt/codex by official group, disjoint/leak-guarded, componentsUrl sourcing from components.json via pickBreakdownComponents. **#693 follow-up**: all three now also SCOPE THE BADGE via a worst-of `statusComponentIds` (exact counts pinned by `SHARED_PAGE_COUNT` in `status-determination.test.ts`) — replacing the old no-statusComponentId overall-indicator path, so a component in none of those groups can't flip the openai badge + the primary `statusComponentId` gives a 30-day calendar; supersedes the #292/#294 guard. **#1008**: "Codex in ChatGPT Desktop" is a ChatGPT-group surface (mis-attributed to codex, dragged its badge to degraded on a ChatGPT-only incident) → moved to chatgpt. **#1010**: Compliance API joined that badge scope — #693 had orphaned it alongside FedRAMP/Ads Manager on a premise the page contradicts (it is a ChatGPT-group member; those two are not). See [docs/reference/status-determination.md](status-determination.md))
    types.ts    # Shared types (ServiceStatus, Incident, etc.)
    utils.ts    # Shared utilities (formatDuration, fetchWithTimeout, sanitize)
    score.ts    # AIWatch Score — composite reliability (Uptime 40 + Incidents 25 + Recovery 15 + Responsiveness 20 from probe p50/CV; 80→100 rescale + 5% penalty for probe-less services, insufficient-data penalty for <7d probe samples). Incidents component uses Atlassian-weighted affected days (#260/#261): null impact excluded, per-day max impact weight (critical/major=1.0, minor=0.3) — symmetric with uptime weighting. Grade thresholds tightened to absorb the upward score shift: excellent ≥90, good ≥75, fair ≥55, degrading ≥40, unstable <40. The score's Recovery component uses a 30-day **median** of resolved-incident durations; for a **thin sample (<3)** the old mean fallback is replaced by an **asymmetric shrinkage** toward a 1h prior (#1019 Part B, `computeMttrHours` + `MTTR_PRIOR_MIN`/`MTTR_PRIOR_WEIGHT`): shrink toward the prior ONLY when the sample mean exceeds it, so a single paperwork-inflated / one-off long incident can't tank a low-incident service (luma/gemini were the live cases) while a genuinely fast recovery keeps its score — no churn on well-performing low-incident services, and it never lowers a score. Pairs with the #1019 Part A duration-override (operator tool) for the specific paperwork cases. NOTE the ServiceDetails "Recovery" metric **card** is a separate display (`src/utils/recovery.js`, #557): a **7-day median + worst** ("typical 15m · worst 29h34m") — same lower-middle median convention as the score, but a different window, so the two can legitimately differ. It replaced a 7-day mean that let one long outage be diluted away by many short component blips (Mistral)
    badge.ts    # SVG badge generator
    rss.ts      # Incident RSS 2.0 feed generation (#54) — buildFeedResponse (400/404/503/200 decision), buildRssFeed, feedSlug↔is-down-slug map (pinned by feed-slug-sync.test.ts). /feed.xml collapses a multi-surface incident to one item via dedupeSharedIncidents (#520). **#724 — Slack `/feed` parity with the Discord embed**: the feed item carries the 🤖 AI analysis summary (the `/feed` handler reads `ai:analysis:{svcId}:{incId}` for active incidents → `RssAiAnalysisMap` → `descHtml`, public-safe — operator-only tweet draft NEVER included), uses a **provider-grouped title** for shared incidents ("Anthropic (Claude API, claude.ai, Claude Code): …" not "Claude API: …"), and ranks "Try instead" identically to Discord (the handler attaches `aiwatchScore` to `services:latest` via `scoreFor` before calling, since `getFallbacks` needs it — `services:latest` carries no score). **#750 — active-item pubDate = first-detected, not backdated `startedAt`**: an active incident's feed item used `incident.startedAt` as its `pubDate`, but a provider-backdated start (BetterStack flap / a #633-held incident surfacing hours late) made Slack /feed treat the new item as "already past" its last poll and **silently drop the outage post** (Discord push still fired; resolved items were fine since they use the fresh `resolvedAtOf`). Fixed by stamping `feed:firstseen:{incId}` once (get-or-set) in the cron's `alerted:new` path and reading it as the active `pubDate` (`buildRssFeed`/`buildFeedResponse` `firstSeen` arg; falls back to `startedAt` when absent) — mirroring why #467 gives the resolved item a *later* pubDate. **#759 — publish-before-analysis hold**: Slack `/feed` dedups by guid and never re-renders a posted item, so an `investigating`/`identified` active item published in the few-second window BEFORE its `ai:analysis` KV write lands froze a forever-AI-less message. `buildRssFeed` now HOLDS (skips emitting) an AI-less non-`monitoring` active item while `now − feed:firstseen < AI_HOLD_MS` (6 min); released once analysis exists OR the window passes (bounded — a genuinely skipped/timed-out incident still posts AI-less). `monitoring` is never held (AI excluded by design), resolved items never held, fail-open when `firstSeen` absent (post not hold). Reuses the `firstSeen` + `aiAnalysis` maps already threaded by the `/feed` handler. **#768 — active-item content is status-INVARIANT**: Slack `/feed` re-notifies on ANY item content change, so an active incident re-posted on every status transition (investigating→identified→…). `descHtml` now renders the active item as **severity emoji + impact label + AI block + fallback ONLY** — the status word, the running duration, and the per-update timeline text (resolved-item-only now) are dropped, so investigating→identified is byte-identical → Slack posts once (with AI, via the #759 hold). monitoring still differs (AI dropped by #724 → one near-recovery re-post); resolved is a separate `:resolved` item. Net per incident: 1 active + (optional monitoring) + 1 resolved. **#776 — the #759 hold needs a `feed:firstseen` anchor, but that was stamped ONLY by the cron's `alerted:new` path, which can run AFTER the incident is already `/feed`-visible (a regular `/api/status` write to `services:latest` precedes the cron). In that pre-cron window the hold failed OPEN (no anchor) → an AI-less item leaked to Slack, which then re-posted when AI landed (recurred 2026-06-24 despite #759/#768). Fix: the `/feed` handler now ALSO stamps `feed:firstseen` (get-or-set via `resolveFeedFirstSeen`, same 7d TTL, first-write-wins) the moment it renders an active incident lacking one, so the hold engages in that window. Side note: this makes `/feed` a SECOND firstseen write surface (was cron-only), widening the population `countNewFeedItems` (#748) counts — harmless, that metric is an explicit upper bound.** **#793 — orphan-resolution suppression (Slack-feed half of #792)**: a short blip whose entire active window fell between Slack's `/feed` polls posted a lone "🟢 Resolved · 19m" with no prior outage item (Langfuse ingestion blip, 2026-06-26). The hold predicate is now the exported `isActiveItemHeld` (shared by `buildRssFeed` AND the handler); the `/feed` handler stamps `feed:active-emitted:{incId}` the first time an active item is actually SERVED (not held), scoped to the services the polled feed carries. On resolution the handler reads the marker → `servedActive` set → `buildRssFeed` **suppresses a resolved item whose active item was never served** (clean miss → suppress; KV throw fails open → emit; `servedActive` absent for direct callers → emit, pre-#793 behavior). Caveat: the marker is per-incId/global, so two subscribers on different-cadence feeds can let one's served-active emit the other's resolved — accepted v1 (dominant single-`/feed.xml` case fixed, strictly better than always-emit). **#860 — conditional GET to cut Slack `/feed` delivery lag**: after a multi-hour Slack silence where the feed was healthy but Slack's RSS poller had backed off (Discord unaffected — it's server-push), both feed endpoints now emit a weak `ETag` (`weakFeedEtag`, FNV-1a over the body) + honor `If-None-Match` → **304** (via `feedHttpResponse` in index.ts; header contract + 304 branch unit-tested without a request/KV mock). The body is byte-deterministic: `buildRssFeed` → `buildFeedWithMeta` (returns `{xml, lastModified}`) stamps `<lastBuildDate>` from the newest item pubDate (was `now`), and an **empty feed omits `<lastBuildDate>`** so the dominant no-incident steady state has a stable ETag and 304s. `Cache-Control` 300→60s + `<ttl>1</ttl>`. `Last-Modified` is emitted but **informational only** — 304 is ETag-ONLY (`isFeedNotModified`), never `If-Modified-Since`, because newest-pubDate is coarser than actual body change (a #759 AI landing / #768 monitoring transition / non-newest-incident edit mutates the body without advancing it) so IMS could false-304 and drop an update. Slack's poll interval itself is Slack-controlled (uncontrollable from our side); this reduces added lag + backoff, not the base interval.
    api-traffic.ts # WAE traffic instrumentation — /api/v1 (#518) recordV1Traffic + queryV1Traffic, AND /feed.xml + /feed/:slug poll volume (#548) recordFeedTraffic + queryFeedTraffic (separate `feed-poll` index, same dataset). AE SQL read-back for the daily report needs CF_ACCOUNT_ID + CF_ANALYTICS_TOKEN secrets. #548 also: daily-summary surfaces the per-user subscriber NEW-today delta (computeSubscriberDelta vs the webhook:sub:count:{date} snapshot) + the feed-poll section; RSS/Reddit outage-share links carry channel utm via appendUtm (utils.ts) — the consent-free retention signals GA4 can't give. **#748 — `countNewFeedItems`**: the daily-summary feed line also shows "· N new items" (incidents AIWatch first-detected in the 24h window, by reading the #750 `feed:firstseen:{incId}` markers — no new write surface), so the mostly-empty poll volume isn't misread as alerts-sent. Polls ≠ notifications: AIWatch is the RSS publisher and can't see Slack's posting decisions, so "delivered" is unobservable — new-items is the alert-worthy-event upper bound. `countFirstSeenWithin24h` is the pure, unit-tested core. **#1157 — `/badge/:serviceId` request volume** (separate `badge-request` index, same dataset): `recordBadgeTraffic(analytics, outcome: BadgeRequestOutcome)` takes a discriminated `{known:true,serviceId}|{known:false}` — NOT a bare string — so the sentinel substitution for a miss happens INSIDE the function (mirrors `v1Variant`/`feedVariant` classifying internally, rather than trusting the call site). A 404 on an unknown/retired/typo'd service id records the fixed `BADGE_UNKNOWN_SERVICE` sentinel, never the raw requested string, keeping blob1 cardinality bounded to (known services + 1) regardless of caller input — recording the raw miss string was the original design and was reverted in review for unbounded/attacker-inflatable cardinality. `queryBadgeTraffic` read-back → daily-summary "🖼️ Badge Requests" section (top-3 known services by count + a separate "N unknown-id" suffix, sentinel excluded from the ranking). A source-scan `badge-wiring.test.ts` guards the `index.ts` cron-assembly call site specifically — mutation-verified against the actual bug this issue shipped (a computed-but-never-threaded `badgeTraffic` local that left all pure-function tests green while the report section never reached production) **#1227 — `cache-read` index**: `recordCacheReadOutcome(analytics, outcome)` books each way `cacheRead` fails to yield a usable status snapshot (see the `CacheReadOutcome` union) on one shared index, so the NEXT unusable-snapshot incident can name its own cause — the previous reader collapsed them into a bare `null` and #1227 could prove the symptom without naming the mechanism. Failure paths only (a healthy worker writes nothing), which also means volume rises with the breadth of the failure, whatever its layer. No read-back helper yet — query the index directly. A route-driving `statusline-wiring.test.ts` guards every consuming handler plus the `curl -sf` coupling in the plugin scripts, for the same reason.
    og.ts       # OG image SVG generator (1200×630 for social share)
    og-render.ts # SVG → PNG conversion (resvg-wasm, Inter font from CDN)
    alerts.ts   # Alert detection logic (buildIncidentAlerts, buildServiceAlerts, buildRegionHint, buildTweetDrafts). **#767**: `buildServiceAlerts` (Service Down/Partially Degraded/Recovered status-EDGE alerts) is a **Tier-1-only safety net** — emitted only for claude/openai/gemini (`API_TIER[id]===1`); non-Tier-1 services rely on incident alerts alone, since a status-edge alert only ever fired in the incident-less gap (`!hasOngoingIncident`), usually a transient indicator-before-incident race the incident alert covers ~1 cron cycle later (the #759 AssemblyAI "Service Down" 6:18 → "New Incident" 6:23 double). buildIncidentAlerts dedups per-SERVICE against `alertedNewMap` (incId→Set<svcId>, the `alerted:new:` roster parsed by `parseAlertedRoster`), so a service JOINING a multi-service incident after the first alert fired (e.g. ChatGPT joining a renamed Codex incident, #545) still gets its own alert; `shouldHoldNewIncident`/`pendingNewKey` (#633) add a first-seen confirmation gate — a flap-shaped NEW incident (`isFlapNotice`: "— down/recovered" title + impact **not `major`**, since #564 maps a BetterStack monitor flap to `minor`/null — NOT all non-null impact) on a `flapSuppression` service (together/huggingface/modal/luma/helicone; fireworks left this group in #1198, now `holdShortIncidents` — see kv-schema.md) is HELD until it survives **~2 cron cycles** (#835 — was one; `pending:new:{incId}` now stores the first-seen epoch ms write-once, 30min TTL, and `shouldHoldNewIncident` confirms only once first-seen ≥ `FLAP_HOLD_MS` ≈9min — added to `suppressedIncIds` while held) so a flap that self-recovers within ~2 cycles never fires a phantom alert + AI analysis (the held set is also passed to `refreshOrReanalyze` to defer analysis) — closing the gap where a flap lingering just past ONE cycle then resolving still New+Resolved double-alerted (Modal "Storage degraded" 1m); a held blip that recovers emits no "recovered" either (the resolved branch's `alertedNewMap.has` guard). Severity-tagged + Tier-1 (claude/openai/gemini) are never held. **#792** generalizes the same hold beyond the flap title shape (#835 widened both to ~2 cycles): `isShortIncidentHoldable` holds ANY non-`major`/`critical` NEW incident (no "— down/recovered" title required) on a `holdShortIncidents` service (langfuse; **mistral #929** — its Instatus/Nuxt auto-monitor posts frequent short `"○○ API Degraded"` MEDIUM→minor flaps that self-resolve in seconds/minutes and are then pruned from the page, so each fired a phantom "New Incident" alert — the 2026-07-03 AI Registry Prompts/Skills case; the 120h Fine Tuning incident survives the hold + alerts normally) — these fire frequent short `minor` blips AND backdate their resolution, so the `*/5` cron often first catches one only as it's already resolving, producing a New+Resolved double-alert the live dashboard never reflected; `shouldHoldNewIncident` now ORs the two predicates (the flap path is unchanged — flapSuppression-only services never hold a normal-titled minor). Sibling Slack-feed half = #793. The **status-source-inactive** operator alert (#689) has its own debounce: `sourceLivenessOf`→`decideSourceDeadAction` (#714) replaced the old boolean `sourceDead` with 3-state liveness (dead 4xx incl. 429 / alive clean-fetch / unknown throw·5xx — `services.ts` sets `sourceUnknown` on the throw + 5xx paths) so a transient hiccup mid-dead-source **holds** instead of firing a false "Recovered", + a #633-style `pending:source-dead:` 1-cycle confirm gate; details in [docs/reference/status-determination.md](status-determination.md). **#800** — a per-service `statusSourceDeactivated` flag (set on `characterai`, Statuspage 401-deactivated since ~2026-06-18, #689) SUPPRESSES the recurring dead-source alerts the operator already acknowledged: `shouldSuppressSourceDeadAlert` drops the #689 rising-edge "Inactive" send (weekly) — the `alerted:source-dead` marker is still written so a RECOVERY still fires — and `checkPersistentFetchFailures` skips the #500 "unreachable 1h+" warning (daily) for the flagged ids. Dashboard (operational+stale via runtime `sourceDead`) unchanged; REMOVE the flag when the page reactivates. `AlertCandidate.svcIds` carries that scoped set so buildTweetDrafts + the per-user feed (alert-feed.ts) target only the joiner, not every service sharing the incidentId. Before sending, the cron collapses concurrent same-provider alerts: `mergeTogetherAlerts` (#283) merges Together AI per-model alerts (blunt all-merge); `mergeXaiRegionalAlerts` (#686) merges xAI per-region alerts that share an event — keyed on the **region-tag-stripped** title (`[API (<region>.api.x.ai)]` prefix removed) so the SAME event across us-east-1/eu-west-1 merges while DISTINCT events stay separate; xAI-only by design. **#940 supersedes the cycle-local half of this**: xAI per-region incidents are now collapsed to ONE canonical incident **at the source** (`services.ts` `mergeXaiRegionalIncidents` right after `parseXaiRssIncidents`), so ALL surfaces (dashboard list, Analyze modal, RSS/Slack feed, Discord new+resolved) see one incident — `mergeXaiRegionalAlerts` (which only merged within a single cron batch → leaked when regions surfaced/resolved across cycles) + the #703 `collapseXaiRegionalIncidents` AI-analysis dedup now receive an already-merged list and are cheap no-op defense. Stable canonical id `xai-evt:<fnv1a(region-stripped title)>` survives partial resolution; worst-of status/impact, `resolved` only when all regions resolved. See [docs/reference/status-determination.md](status-determination.md). Both set `AlertCandidate._mergedKeys` so every collapsed incidentId still lands in the `alerted:new:` roster (no re-fire) and the daily count tallies each region/model. buildRegionHint (#422 Phase 2) reuses the Edge region-status port (api/_is-down/region-status.ts) — imported, not re-copied — to append a "📍 Try region: <label>" line to new-incident Discord embeds for region-aware services with a region-specific partial outage. buildTweetDrafts (#348 Phase 1.5 / #521) returns one X compose (Web Intent) link per affected Claude/OpenAI-family service (claude/openai/claudeai/chatgpt/claudecode/codex; slugs pinned by tweet-draft-slug-sync.test.ts) so the operator picks which surface to post; appendTweetDraftSection length-guards it under Discord's 4096 limit — operator embed only, never the per-user relay. **#777**: `buildTweetSearches`/`buildTweetSearchUrl` add a companion `🔎 FIND TWEETS TO REPLY TO` link (operator embed only, appended right after the draft via `appendTweetSearchSection`, same 4096 guard) — the draft answers "what to post", this answers "where": an X **Top-tab** search (`is {service} down`, `&f=top` engagement-sorted; Latest dropped — operator flips tabs on the result page) so the operator finds the already-viral outage tweet to REPLY to (replying rides existing engagement; 2026-06-23 one such reply drove ~38 GA4 new users, all Twitter). Plain natural phrase, NOT advanced operators (an early `min_faves:N -filter:replies` query returned 0 results). Scope = `TWEET_SEARCH_TERMS` (the 6 tweet-draft services + `gemini`), pinned by tweet-search-scope.test.ts. `appendTweetSearchSection` renders `buildReplyDraft`'s ONE casual, copy-paste-ready reply (`🔴 yes — {service} is down right now… → {is-down link}`; #936 leads with a 🔴/🟠/🟢 status circle) — the 🐦 compose link can't pre-fill a *reply* to someone else's tweet, so the operator copies this and pastes it. **#936**: the reply is no longer an in-embed ``` code block (Discord's one-click Copy is DESKTOP-only; mobile long-press copies the whole embed). The embed now shows a one-line pointer (`💬 REPLY DRAFT in the message below ↓`) and the cron sends the reply text as its **own plain-text operator message** right after the embed (`sendDiscordMessage` in index.ts, `flags:4` SUPPRESS_EMBEDS, isolated try-guard) so mobile "Copy Text" grabs exactly the reply. Primary = first in-scope service in svcIds (one reply per alert, even grouped); link slug from the canonical `SERVICE_ID_TO_SLUG` (covers `gemini`, which `TWEET_DRAFT_SERVICES` lacks). The reply link carries `X_REPLY_UTM` (= `X_UTM` + `utm_content=reply`) so GA4 splits reply-driven inflow from the 🐦 standalone-compose draft while both still roll up under `campaign=outage` — testing the #777 hypothesis that replies out-convert fresh posts. When space is tight the section trims in priority order: extra service links (`+N more`) → reply block → whole section, so the alert always sends. `defuseAutolinkDomain` (#535→#539, exported from alerts.ts, used by rss.ts + reddit.ts too) renders the bare `claude.ai` brand as `claude ai` everywhere it reaches a social surface as plain text — the operator embed (title+desc+tweet blockquote/label, #535) AND the tweet/RSS/Reddit message **text + intent URL** (#539, since the operator pastes the tweet into Slack where a bare domain auto-links). `appendStatusHint(url, hint)` (#539, utils.ts) appends `?e=resolved|active|down|degraded` (Reddit: `?e=reddit`) to the shared is-X-down link so a recovery share is a DISTINCT URL from the outage share → social platforms (which cache the OG unfurl by page URL) re-fetch a fresh card instead of showing the stale one. The is-down Edge now **reads `?e=` to PIN the og:image card status to the share moment** (`HINT_TO_OG_STATUS` in html-template.ts: down/degraded → that status, active/resolved → operational; reddit/absent/unknown → live status) — so a "Claude down" tweet's unfurled card shows Down even if Claude's live status already recovered by unfurl time (the old behavior baked the live status, which drifted between draft and post). **#874**: the `?e=` hint is overloaded — `alerts.ts` (tweet/reply) emits `active` ONLY for an operational service, hence `active → operational`; but the **RSS/Slack feed** (`rss.ts serviceLink`) used to ship the coarse `ItemKind` `active` for ANY ongoing incident, so a genuine degraded/down outage mis-pinned the Slack unfurl to a green Operational card. `serviceLink` now derives the hint from the live `service.status` for active items (operational→`active`, else `degraded`/`down`; `resolved` unchanged), mirroring the alerts.ts derivation, so the feed card matches the real severity. The page BODY + `<title>` + canonical stay LIVE/clean — only the social card meta is pinned. **#740-followup**: `og:url` + `og:title`/`twitter:title` pin to the hint TOO (social platforms cache/dedupe cards by `og:url`, NOT the fetched URL — a query-less `og:url` collapsed every `?e=` share onto one stale cached card, so the IMAGE pin was defeated; canonical stays clean for SEO). **#804**: even with `?e=down` pinned, the og:url was byte-identical across DIFFERENT outages (`is-{slug}-down?e=down` + constant UTM), so a NEW outage within the ~7-day card cache reused the PRIOR outage's stale card — `incidentTokenForAlert(alert)` (alerts.ts, exported) appends a per-incident `&i=<incId>` (the `alerted:new:`/`alerted:res:` key tail; null for status-EDGE alerts whose tail is a svcId) to the tweet/reply share link, and the is-down Edge reads `?i=` (sanitized to id-safe chars, colon-bearing Gemini `aistudio:` ids deterministically collapsed) into `og:url` ONLY (canonical/`<title>`/JSON-LD stay clean) so each incident is a distinct card identity → fresh re-scrape. **#1103** carried the same pin one layer further — into the og:IMAGE url, which kept moving on a wall clock beneath the frozen og:url; that half lives with the code, in the `api/_is-down/html-template.ts` entry above. Separately fixed a font-fetch race in `og-render.ts`: the Inter-font fetch is now memoized as a shared promise (not a boolean flipped BEFORE the await), so concurrent `/api/og` renders can no longer construct Resvg with an empty font buffer → text-less OG PNG (the Slack "black box")
    fallback.ts # Fallback recommendation (getFallbacks flat top-2; getGroupedFallbacks + buildGroupedFallbackText per-category for multi-surface incidents, #781 — used by the Discord alert + RSS feed)
    service-groups.ts # #1068 — the FINE service taxonomy (llm/agents/voice/inference/observability/video/image/apps) as a group→ids map, exposed per-service on /api/v1/status as `group` (beside the coarse `category`). A duplicate of the frontend `SERVICE_CATEGORIES` (the worker can't import constants.js — it reads import.meta.env), pinned ↔ it by `service-groups-sync.test.ts` (same #403 api-tier-sync discipline). Lets external consumers that can't import the frontend (aiwatch-reports#98's report breakdown) derive category counts from data
    anthropic.ts # #955 — SINGLE source of truth for the Anthropic Messages REST call (model id `ANTHROPIC_MODEL` = `claude-sonnet-5`, gateway URL, request body, retry + status classification), shared by ai-analysis.ts + monthly-narrative.ts, which each used to hardcode the now-retired `claude-sonnet-4-20250514` (pinned #21; it passed its 2026-06-15 retirement, 404'd, and both call sites swallowed it into a bare `return null`). `callAnthropicMessages` NEVER throws — it returns a typed `AnthropicOutcome` (`ok`/`permanent`/`transient`/`aborted`), so a retired-model 404 (retry can never help) is distinguishable from a 529 overload (retry is exactly right); before #955 every non-2xx collapsed to `null` and there was no retry anywhere in the AI path. Retries once on 408/429/5xx honouring `retry-after` (capped 2s), fails fast on other 4xx. `anthropicRequestBody` sends `thinking:{type:'disabled'}` — omitting it selects Sonnet 5's ADAPTIVE thinking, whose tokens come out of `max_tokens`; a determinism guard, not a repair (measured 2026-07-09: no thinking block emitted either way on a representative prompt). Model id + `thinking` pinned by `anthropic.test.ts`. Full failure taxonomy + verification: [discord-alert-paths.md](discord-alert-paths.md)
    ai-analysis.ts # Hybrid AI incident analysis — Gemma 4 26B (Workers AI) primary + Claude Sonnet (AI Gateway) fallback (system/user prompt, needsFallback assessment, TTL refresh, re-analysis, incidentId dedup, timeline context, boilerplate filtering, formatRecoveryDisplay). **#827 Feature 2 — RAG grounding**: buildAnalysisPrompt prefers a durable-corpus history block (`buildHistorySection`: each past incident's ACTUAL recovery + our prior estimate's accuracy + prior AI read) over the in-memory title-only list when records exist (falls back before the corpus accumulates). analyzeIncidentDetailed takes a `historyRecords` arg (caller reads `incident:history:{svcId}`); refreshOrReanalyze reads it lazily per re-analysis. Gives the estimate provenance + lets the model self-calibrate against its own track record. **#955**: `analyzeIncidentDetailed` returns `{result, failure, attempts}` and is the only HYBRID entry point (cron inline via `analyzeIncidentWithBudget`, `refreshOrReanalyze`'s injected `analyzeFn`, and `/api/admin/analyze`; the `analyzeIncident`/`analyzeWithSonnet` wrappers were deleted, and `analyzeWithSonnetDetailed` serves the admin `model=sonnet` path directly). `failure` drives `reanalysisLockTtlSec` (30min for `permanent` only; `transient`/`aborted` write NO lock, so the next cron cycle retries inside the #882 AI-hold window). `INLINE_ANALYSIS_BUDGET_MS` (15s) races the analysis's ORDINARY promise and propagates its `AbortSignal` into the Sonnet fetch, replacing an uncancellable 8s `Promise.race` that booked a paid-for late response as `failed`; the Gemma leg is awaited plainly (Workers AI has no abort hook). `applyAttempt`/`parseUsage`/`recordUsage` centralize the `ai:usage` counters across all three write sites
    incident-history.ts # #827 keystone + RAG corpus — durable per-service resolved-incident corpus (`incident:history:{svcId}` KV, no TTL, capped 50, dedup by incId). On resolution the cron joins the AI prediction (predictedRecoveryHours/summary/model from the expiring ai:analysis key) with the actual outcome (durationMin) into one record that outlives the 2h ai:analysis/recovered TTLs → both the prediction-accuracy ledger (Feature 1, `accuracyOf`) AND the RAG corpus analyses retrieve from (Feature 2). `appendIncidentHistory` best-effort + idempotent; `findSimilarHistory` (Phase 1: same-service title-overlap retrieval, cross-service-capable for the Phase 3 graph) + `formatDurationMin` + `readIncidentHistory`/`durationMinOf` pure, unit-tested. **#846** — also exports the shared `resolvedAtOf` (moved out of rss.ts; resolvedAt→last-`resolved`-timeline→last→start) + `resolvedPredictionLine` (`🎯 AI prediction: 45m (within ~45m est.)`, null when no numeric estimate) used by BOTH Slack `/feed` (rss.ts) and the Discord **`alerted:res:` Incident-Resolved embed** (index.ts) so the two surfaces show byte-identical prediction wording. Previously the 🎯 line landed ONLY on the rarely-firing Tier-1 `alerted:recovered:` `recoverySection` (#827 F4), so Discord never showed it on normal resolutions while Slack did. **#847 — corpus accrual fix**: the same `alerted:res:` block now ALSO writes the durable history record (`buildHistoryRecord`→`appendIncidentHistoryBatch`) for EACH affected service, so the #827 corpus accrues on normal incident resolutions — previously it was written ONLY in the `alerted:recovered:` block (Tier-1 status-edge, incident-less gap), so normal resolutions recorded nothing and the accuracy ledger + RAG corpus stayed near-empty. Idempotent (dedups by incId → the two alert paths can't double-record). Grouped incidents record once per surface (each surface's RAG corpus grows); `summarizeAccuracy` dedups by incId so that doesn't multi-count the shared prediction in the accuracy metric
    recovery-mark.ts   # #1003 — the shared "mark this incident resolved" step. Two cron paths end an incident: the SERVICE-status-edge `alerted:recovered:{svcId}` block (whose own comment calls it "rarely-firing … only fires in the incident-less gap") and `alerted:res:{incId}` (the Incident-Resolved alert — the path that actually fires for a normal incident). The two writes EVERY resolved-incident read surface is gated on — the `recovered:{svcId}:{incId}` marker (2h) and the `resolvedAt` stamp on `ai:analysis:*` — were written ONLY by the rare status-edge path, so on a normal resolution the "Recently Resolved" banner, the Analyze modal's predicted-vs-actual verdict, and the is-down AI card's "Predicted vs actual" all rendered NOTHING while Discord + Slack `/feed` shipped the same information fine (#827 F4's UI was reachable only through the rare path it happened to be wired to). This is the THIRD instance of that same drift — #846 moved the 🎯 line, #847 moved the durable corpus write, and #1003 moves the read-surface half — so the step now lives in ONE module both paths call. `markIncidentResolved` writes the marker even with no analysis (the outcome is worth surfacing without a prediction), stamps `resolvedAt` through `putAnalysis` (so the #1003 scoring baseline is preserved AND self-heals into the durable `ai:first-est:` key), is idempotent (an already-stamped analysis is never rewritten, so a Tier-1 incident tripping both paths marks once), measures duration to the incident's own `resolvedAt` rather than the cron cycle, and drops a corrupt analysis rather than serving it. Returns the analysis for `buildHistoryRecord`. Best-effort throughout — a bookkeeping write must never abort an alert that is about to ship. Call sites pinned at source level (`recovery-mark.test.ts`), since nothing drives the cron `scheduled` handler in tests
    withdrawn.ts       # #1106 — tombstones for an incident the provider DELETED from its status page instead of resolving it (observed live on Mistral: a 🔴 New alert went to every channel, the incident was removed ~4 days later, and nothing ever closed the thread). The #975 prune correctly removes the stranded accumulator row, but every closing path is built from an incident PRESENT in the live list with `status === 'resolved'` — `buildIncidentAlerts`' resolved branch and the RSS `:resolved` item both — so for a deleted incident those branches are structurally unreachable, and the RSS half is the worse one: an `active` item silently disappearing from the feed does NOT retract the message Slack already posted into a channel (#467 gives the resolved item a distinct guid for exactly this reason). The prune is the LAST moment the title + start time exist anywhere, so this module captures them; the tombstone is then the only material `buildWithdrawalAlerts` (alerts.ts) and the RSS `withdrawn` item have to render from. `diffPrunedIncidents` derives the pruned set from a before/after `incidentIds` diff inside `accumulateIncidentsOnlyIfChanged` rather than changing `prunePhantomIncidents`' signature — that fn is pure and its guards are load-bearing (#975), so it stays untouched; the diff is sound because accumulation only ever ADDS ids and the prune is the sole removal path. Fails toward SILENCE everywhere: a missing service key, a missing detail row, and an unreadable roster all emit nothing, because the one thing worse than an unclosed thread is a fabricated closing notice. **Not the #975 prune's fault and not a change to it** — the prune's four guards all held legitimately here; this is the notification half nobody had written. Both emitters are gated on the marker their own announcement wrote (`alerted:new:{incId}` / `feed:active-emitted:{incId}`), so an incident we never announced produces no orphan retraction, and both apply the shared `withdrawalHold` — the notice is withheld unless the service is cleanly `operational` AND its status source was readable this cycle, because the #975 prune's dominant real case is delete-plus-re-publish under a NEW id (Pinecone) where "withdrawn" would be a public falsehood about a running outage, and because an unreadable source yields an empty incident list that is indistinguishable from "nothing is running"
    withdrawal-log.ts  # #1106 Part 5 — the DURABLE half of the withdrawal feature: one row per provider-deleted incident, and whether its ⚪ closing notice actually went out. Exists because every other trace expires fast by design (`incidents:withdrawn` 48h, `alerted:wd:` 7d, `alert:feed:recent` 2h, Workers Logs ~3d on the free plan) and the accumulator row is gone by definition — its prune is what starts the whole sequence — so `archive:monthly` is structurally missing the incident too. Past a week the honest answer to "did the ⚪ path ever fire?" was *we don't know*, and #1106's own exit condition is exactly that production observation: unschedulable (it needs a provider to delete an announced incident), so a `verify-after` date would fire on an absence and prove nothing, and a Tier-A `assert:` had no endpoint to read. The instrumentation is therefore the deliverable, not a nicety (same judgement as `decision_instrumentation_is_the_deliverable`). TWO write points and no third: the prune records the row un-announced (called beside `appendWithdrawn` in `accumulateIncidentsOnlyIfChanged`, after the roster so a failure here can never cost the notice itself), and the cron's `alerted:wd:` dedup write stamps `announcedAt`. The verdict that matters — a thread opened and never closed — is DERIVED on read (`isPermanentlyUnclosed`: no `announcedAt` and `prunedAt` older than the roster's own TTL, imported rather than restated so the two cannot drift), which is why nothing has to come back later and flip a stored flag. `markWithdrawalsAnnounced` checks the current AND previous month because a 48h tombstone can cross a month boundary, and a row left in the old month would read "never closed" forever — the false positive that would make the log untrustworthy. Holds are deliberately not rows: `withdrawalHold` re-evaluates every 5 min, so a held notice is a STATE, not an event; one row per evaluation would turn a bounded log into a per-cycle write, and a hold that never clears already shows up as an un-announced row. Both writers are first-write-wins (a re-prune must not move `prunedAt` — it is the clock the verdict is derived from — and a re-sent notice must not move the moment the thread closed), and a read failure ABORTS the month's write instead of starting from `[]`, the same reasoning `accumulateIncidentsOnlyIfChanged` applies to its own read: an empty start would republish the month and destroy the history the key exists to keep. Read-only surface: `GET /api/admin/withdrawals?month=YYYY-MM` (X-Admin-Key), which 502s on an unreadable value rather than answering zero — an empty `[]` would read as "no provider ever withdrew an incident", the exact false negative this module exists to remove. No POST: nothing an operator could edit here would be anything but a falsified history. KV not WAE (#518/#548) — withdrawals are single digits per MONTH, not traffic-proportional, and the question asked of them is "show me the rows", not "aggregate a rate"; the shape mirrors `growth-series.ts` (#986)
    upstream-link.ts   # #1053 — the CROSS-PROVIDER upstream link: a dependent's own incident names an AI provider that is itself impacted (cursor → Anthropic 2026-07-17; replicate → Hugging Face 2026-07-16, a +36m lead). Sibling surfaces of ONE status page already group by `inc.id` (claude/claudeai/claudecode all read status.claude.com; `Overview.jsx` dedupes on it, #1045 does the same for the Recently Resolved banner) — this layer exists only for the case where the dependent files on its OWN page, so no id can ever join them. NOT an extension of `supply-chain.ts` (#574): that layer's headline is REGION-scoped and its `awsRegionsNamedByService` doc deliberately drops any incident naming no region — which both evidenced cases are; loosening that premise is what #1000 had to undo. It also needs a dedicated health feed (`bedrock.awsRegionHealth`), whereas an upstream here was originally always an ordinary `services[]` entry — **#1072 ended that**: an upstream may now also be a NON-CARDED feed from `upstream-feed.ts` (`github-platform`), which is itself a dedicated health feed, so the contrast is now only that supply-chain's is region-scoped and rides on a real service. FIVE gates, all required (the AND is the design — each alone produces false links): the pair is DECLARED in `UPSTREAM_DEPS` (curated, never inferred — the moat, and what separates us from AIDown.io's static dependency map); the dependent is degraded/down (so no always-on "X depends on Y" banner); the dependent's OWN active incident NAMES the upstream (we report THEIR claim, never our theory); the upstream is itself impacted with an active incident; and the upstream's incident STARTED FIRST, within `CAUSE_WINDOW_MS` (24h). TWO freshness bounds, both needed: gate 3 also anchors the CLAIM to `now` (a REQUIRED param, so the type-checker makes every call site state its clock). The window alone is RELATIVE — it bounds `claim.at - cause.at`, so two mutually-close STALE incidents satisfy it and render `Hugging Face — Down · "Minor: docs search slow" · Started 7d ago` while HF is genuinely down today for an unrelated reason (with today's real incident excluded, since it started after the stale claim). 24h is not derived to a point — the evidence bounds a RANGE (above the 36m/29m observed leads, below the days-old `minor` it must reject) and 24h sits in that gap; 6h would do as well. Widen it only against a real incident that needed the room. Specificity is real, not theoretical: Replicate's own July incidents (`High contention on H100 hardware`) name no upstream, so gate 3 keeps them silent where a static map would have decorated them all. Of the qualifying upstream incidents it quotes the MOST RECENT — the first cut took the earliest and would have quoted a weeks-old `minor` advisory (only `impact: null` is filtered) as the chain-starter, the exact misattribution the module exists to prevent; the evidenced fixtures hid it because both HF incidents were same-outage siblings 88s apart. Accepted cost: a dependent saying only "model provider issues" names nothing and is missed — fail-closed, the same trade supply-chain makes. `UPSTREAM_DEPS` ids are pinned against `SERVICES` for the DEPENDENT side and against `SERVICES ∪ UPSTREAM_FEEDS` for the UPSTREAM side (#1072 — a feed can be an upstream, never a dependent, since it has no card to annotate), because a rename is byte-identical to a healthy gate (the card would simply never render again, with every test green). Serialized UNCONDITIONALLY on both `/api/status` and `/api/status/cached` (an empty array when quiet) — unlike its `supplyChainBanner`/`alertFeed` neighbours — because the gate fires a handful of times a year and the worker deploy is manual: a conditional key would make "never deployed" indistinguishable from "deployed and quiet" forever (#574's banner has that shape and has sat verify-blocked ever since). Consumed by `api/_is-down/upstream-note.ts` → the is-down "Related Upstream Incident" card
    upstream-feed.ts   # #1072 — NON-CARDED upstream feeds: a status source read ONLY to answer "is this upstream impacted right now?" for `upstream-link.ts`. No card, no is-down page, no Score, no uptime, no daily counters, no service-count change — it never enters `raw`/`enriched`, so scoring, daily counters, alerts, badges and the service count cannot see it (it IS a third `fetchAllServices` return field — that is how it reaches the cache writers). **Why it had to exist**: on 2026-07-19/20 a GitHub Actions outage took ChatGPT and Codex with it and OpenAI said so in its own title (`Elevated errors for GitHub-dependent ChatGPT and Codex workflows`, +60m after the GitHub incident), but #1053 could not fire — its upstream must be a monitored `services[]` entry that is impacted, and the only GitHub entry is `copilot`, scoped to `Copilot` + `Copilot AI Model Providers`, BOTH of which stayed `operational` throughout. Only `Actions` / `API Requests` broke. The tempting fix — adding those component ids to `copilot.statusComponentIds` — is the #1008 cross-product attribution bug: every Actions outage would redden the Copilot badge and damage its uptime/Score while Copilot itself is fine. A feed keeps the signal and the badge separate, which is the whole point. Adding GitHub as a 45th SERVICE was rejected instead: it is not an AI service and would drag in the entire `adding-a-service.md` checklist (is-down page, `/methodology` count + category lockstep, reports site, SEO) against `decision_depth_not_breadth`. **Zero extra subrequests** — built from `fetchAllServices`' EXISTING prefetch map, and `UPSTREAM_FEEDS[].apiUrl` MUST be a page some service already fetches (test-pinned; `copilot` already fetches githubstatus). Status resolves from component IDS (worst-of, never the page-level indicator — that would trip on any GitHub incident, including ones confined to components no dependent has blamed); incidents filter by component NAMES, derived live from the id lookup, because `parseIncidents` emits `componentNames` and never `componentIds`. Feed components must stay DISJOINT from any service's badge components (test-pinned — overlap is #1008 from the other side), and a feed id must not collide with a service id (services win a collision, so a colliding feed would be a silent no-op). `UpstreamCandidate` is deliberately NOT a `ServiceStatus`: a feed has no honest `category`/`provider`/`latency`/`uptime30d`, and the narrow type is what stops it being passed anywhere a service is accepted. It DOES carry `statusUrl` (absent on services, whose `statusUrl` lives on `ServiceConfig` and never reaches this layer) → the is-down note links a feed upstream to its OWN status page with `target=_blank rel=noopener noreferrer` + GA `destination=external`, since there is no is-down page to keep the reader on. **Logging is the whole safety net and is throttled** (10min per distinct message, module state like `index.ts`'s `lastKvWrite`): this runs inside `fetchAllServices`, which `/api/status` calls per REQUEST, and every condition it reports persists for hours-to-days. Warns/errors on total component-id drift (a permanent silent no-op — the feature's healthy state is ALSO silence, so nothing else would ever surface it), partial drift, an unrecognized component status (`normalizeStatus` defaults to `operational`; a service would self-report via a wrongly-green card, a feed cannot), and impacted-but-no-attributable-incident (gate 5 can quote no cause, so the link stays silent THROUGH a live outage — reachable: GitHub publishes incidents with `components: []`). The feeds ride in the `services:latest` KV snapshot beside `services`, threaded through ALL THREE writers via REQUIRED params (`cacheWrite`, `writeStatusCache`) — two of the three fire only on a status EDGE, so a writer that dropped them would erase them exactly when an outage begins (#1003's dual-path failure). `isCacheStale` returns them too, so a fresh-cache cron carries them forward instead of erasing them on its #488 alert-edge write
    incident-text.ts   # #1053 — the shared "which of this service's incidents can be a CAUSE, and what text may name it" primitive, extracted from `supply-chain.ts`'s `awsRegionsNamedByService` at the moment a second attribution layer needed the identical rules (the second copy is the extraction point). Three filters: `resolved` skipped; `impact === null` skipped (a Statuspage `none` means the provider itself claims no availability impact, so it cannot be a cause — mirrors `awsHealthImpact` dropping #707 advisories from region health); and the text harvests title + componentNames + **TIMELINE**. The timeline read is load-bearing and was earned from real data (2026-07-13): Hugging Face titled an incident `Elevated error rate – AWS CDN (Singapore)` — a human place name, no region token — and named the region only in an update body, so title-only extraction left it permanently unattributable despite explicitly blaming AWS; Pinecone is the opposite, front-loading `[AWS][us-east-1]` into the title. Both layers ask the same question and differ only in the needle (AWS region tokens vs an upstream provider's aliases); #1053's gate 5 also uses it text-free, purely to enumerate an upstream's cause-eligible incidents — so the FILTERS, not just the harvest, are the shared part
    changelog.ts # Changelog/news collection (OpenAI blog RSS, Google AI blog RSS, Anthropic /news HTML parsing) — 15s timeout + 1 retry on transient errors, per-source last-fetch KV markers for stale-source detection (#274)
    weekly-briefing.ts # Weekly Discord briefing (changelog + incidents + stability trends + AI-analysis usage trend #995 + badge-repo-discovery #1158)
    badge-repo-discovery.ts # #1158 — GitHub repo discovery for badge embeds (weekly Code Search sweep, global "used by" list — no per-service breakdown). Answers "who" embeds an AIWatch badge, distinct from #1157's WAE "how many" (anonymous request volume). `searchBadgeEmbeds` calls `GET /search/code` with the `GH_CODE_SEARCH_TOKEN` secret (classic PAT, `public_repo` scope — distinct from `GH_DISPATCH_TOKEN`'s `actions:write`), single `per_page=100` request (no pagination — accepted v1 limitation), excludes `bentleypark/aiwatch`'s own README/source hits (verified against a real API response during development — ALL early hits were the aiwatch repo itself). `diffBadgeRepoDiscovery` is the pure dedup core (persisted `badge:repos:seen` KV set, permanent, see kv-schema.md) — reports only NEWLY-found adopters per week, `formatBadgeRepoDiscoverySection` renders the weekly-briefing "🔗 Badge Adopters" line, omitted on a quiet week (mirrors weekly-briefing.ts's Security section convention). `parseBadgeReposSeen` (mirrors `parseStrategyBrief`) is the tolerant-parse core for the persisted set — returns `[]` only for a genuinely-absent key, `null` for anything corrupt/wrong-shape, so index.ts's cron block can fail CLOSED (skip the diff+write, same `component-seen:` #992 discipline) rather than substituting a false-empty baseline that would silently overwrite real history on a KV read hiccup. Best-effort throughout — never affects `/badge/:serviceId` serving, fully separate cron branch
    daily-summary.ts # Expanded daily Discord report (uptime, latency, AI usage, Reddit, Web Vitals). #827 Feature 1 — `formatAccuracyLine` renders the `🎯 AI Recovery Prediction Accuracy` line (hit-rate / median abs error / over-vs-under bias) from `summarizeAccuracy` over the durable incident:history corpus (cron reads it one-GET-per-service; omitted until the corpus has a predicted+resolved incident). **#944** — `formatStatuslineTrafficSection` splits the 📟 Statusline Polls report into two COHORTS instead of one blended total: **Server-render (#918)** = path-tagged presets (the adoption signal, + per-preset breakdown) vs **Legacy/untagged (apex proxy)** = the `?src=statusline-proxy` catch-all (pre-#918 jq installs + all other apex `/api/status/cached` traffic; the `vercel.json` rewrite tags ALL apex hits, so it never fully migrates to 0 — labelled neutrally, no "migrating" claim). Each carries a ▲/▼ day-over-day delta from the `statusline:cohort:{date}` KV snapshot (`computeStatuslineDelta`, null-per-cohort on absent/corrupt baseline — mirrors the #548 `computeSubscriberDelta` pattern). The `legacyProxy>0` guard is zero-suppression (consistent with omitting zero-count presets / the whole section at total 0); reachable for self-hosters whose statuslines skip the apex proxy. Pure `parseStatuslineTrafficResponse` (proxy→`legacyProxy`, not `byPreset`) / `computeStatuslineDelta` / `formatStatuslineDeltaSuffix` unit-tested
    monthly-archive.ts # Monthly reliability archive (uptime, score, incidents, latency per service, permanent KV). Also aggregates probe-degradation:monthly (#511, RTT degradation total/noStatus via summarizeDegradation) into MonthlyArchive, exposed by /api/report (#679 removed the structurally-null detection-lead summary). #809 — each per-service entry also carries the static `addedAt` (from `SERVICE_ADDED_AT` in services.ts; absent = established service) so the report-side coverage gate (aiwatch-reports#45) can detect a mid-month-added service by comparing it to the report month — NOT `coverageDays` (live/now-relative, wrong for a historical month). **#909** — `buildMonthlyArchive` also EXCLUDES a service whose `addedAt` is *after* the month's last day (`existedInMonth`, services.ts), so a REBUILD (which reads the current `services:latest` roster via `scoreData`) can't inject a POST-month-added service — e.g. turbopuffer/twelvelabs (added 2026-07) leaking into the June archive's monitored count / "zero incidents" line / uptime+latency tables; the #802 coverage gate only excludes from *ranking*, not archive membership. Genuine mid-month adds + established services are kept (fail-open on a malformed `addedAt`). **#915** — `buildMonthlyArchive` derives `totalDowntimeMin`/`longestIncidentMin`/`avgResolutionMin` from the per-incident list (`aggregateIncidentDurations`), NOT the accumulator's `totalMinutes`/`longestMinutes`, which grow MONOTONICALLY (`accumulateMonthlyIncidents`: `if (dur > oldDur)`) and lock in a long-open incident's inflated open-window duration — never corrected down when it resolves shorter (Deepgram June read 176h42m/141h10m vs the real 45h33m/27h). The per-incident `durationMin` IS updated to the final value, so it's the source of truth; the accumulator is the fallback only when the list is TRUNCATED to the per-service cap. **#1210** — that aggregation also EXCLUDES `autoMonitor` entries, the set `isReliabilityIncident` (score.ts) already keeps out of the Score, so the archive cannot state a downtime its own uptime and Score contradict (Kimi 2026-07, as archived before the patch: 35 of 40 entries, 619h16m beside `officialUptime: 100`). One outage opened hourly and bulk-closed archives N paperwork durations for one event. Neither exclusion applies on the TRUNCATED branch — `buildMonthlyArchive` warns there, since an hourly auto-monitor is the profile most likely to reach the cap. It also emits `countedIncidents` (the divisor `avgResolutionMin` used; `!== incidents` exactly when something was excluded), which `monthly-narrative.ts` names in the prompt so an AI draft cannot read "40 incidents, 9m avg recovery" off a mismatched pair; `selectIncidentCandidates` skips flagged entries for the same reason, but ONLY where the aggregates excluded them too — on the truncated branch they were counted, so skipping there would recreate the contradiction from the other side. Correct a frozen pre-fix month with `scripts/patch-archive-automonitor.mjs` — it emits a patched document and prints the wrangler commands for the operator to run, and refuses anything it cannot reproduce from the stored list. Never `/api/admin/rebuild-archive` — see the `resolveArchiveOfficialUptime` docstring for why that endpoint is not idempotent. **#827 Feature 3** — also aggregates AI recovery-prediction accuracy for the month (`buildMonthlyAccuracy`: reads each service's `incident:history` corpus, filters to `resolvedAt` in the period, `summarizeAccuracy`) into `MonthlyArchive.predictionAccuracy` (`AccuracyStats` or null when no predicted+resolved incident that month), exposed via `/api/report`. The report site (aiwatch-reports) renders the "AI Prediction Accuracy" section from it — deferred until ≥1 full month of corpus accrues (~Aug for July). Caveat: the corpus is a rolling per-service cap, not month-bucketed (best-effort for very high-volume services over old months)
    uptime-archive.ts # #1017 — durable per-day calendar archive read-side. A provider status-page migration resets the LIVE source's per-day records (#1004 Junie/JetBrains), silently blanking the 30/90-day calendar until the new page accrues its own history again — `ServiceStatus.uptimeWindowDays` (present + short) is the disclosed signal that happened. `restoreArchivedCalendar` fills exactly the resulting gap from `history:{date}`'s `weightedOutageSec` (see `daily:{date}` in kv-schema.md and index.ts's `cacheWrite`, which folds it in on the SAME write as `officialUptime` — +0 new KV writes), classified into minor/major/critical via `classifyArchivedDay`'s seconds thresholds (an APPROXIMATION — the original incident's exact severity/duration doesn't survive being folded into one number, but "roughly how bad" beats a blank cell). Live `dailyImpact` entries for a day always win (`dailyImpactHasDate` matches both key forms — bare-date and incident.io's full-ISO-prefix, #693 follow-up) — this only ADDS days the live source has forgotten, never overwrites. Gated on `uptimeWindowDays` being present, so the extra `history:` reads are paid ONLY by a service actually flagged short (rare) — the common full-window path never touches this. Wired into `index.ts`'s throttled `cacheWrite` cycle only, NOT the event-driven edge refreshes (`cache-refresh.ts`, #488/#1057) — see the function's own doc comment for that tradeoff. The write-side computation (`todayWeightedOutageSec`) is populated by all 5 "official" uptime sources via TWO different mechanisms: incident-io.ts / instatus.ts×2 / flashduty.ts / onlineornot.ts each add a second cheap `weightedDowntimeSeconds` call over their already-built interval list (`startOfTodayUTC` in `uptime-interval.ts`, today's window instead of 30d); statuspage.ts instead reads the provider's own last-published per-day bucket directly (no `OutageInterval[]` involved — see the `nowMs`-verified "is this bucket actually today's" check in `parseUptimeDataSingle`). Both thread through services.ts onto `ServiceStatus`; deliberately NOT wired for Better Stack (`platform_avg` — a genuinely different weighting scheme, #1110, mixing them would misrepresent the archive)
    monthly-narrative.ts # AI retrospective narrative (Notable Incidents + Observations draft) baked into the archive — hybrid Gemma→Sonnet, #426
    vitals.ts   # Web Vitals aggregation (ingest, KV flush, p75 computation, Discord formatting)
    growth-series.ts # #986 — the durable daily series the #547·16 lift measurement reads. `referral:out` (2d TTL) and `webhook:sub:count` (7d) expire before enough days accrue to compare outage days against quiet ones, and nothing else kept them: the cron printed each into the Discord daily report and let it lapse, so "did the low-friction CTA raise conversion?" was un-runnable BOTH retroactively (already expired) and going forward (would expire before the window filled). Mirrors the `probe-degradation:monthly` pattern — at the `buildDailySummary` call site the cron already holds every value, so one row is appended to the permanent `growth:daily:{YYYY-MM}` key with one write/day (and, since #1117, three extra reads — see below). **The outage-day axis is `incidentsStartedInWindow` (#1117) — starts only — counted from the durable `incidents:monthly` record over the SAME 24h window the WAE audience query uses.** It replaced `alertCounts` (`alert:count:{date}`), which had already replaced `result.newCount` (a single 5-minute cycle's alerts, which would file an 04:00 outage as quiet) but was itself only ever a PARTIAL day: the key is read at the 09:00 run, so 00:00-09:00 is all it can hold and the rest expires unread — production held 23 alerts for 2026-07-21 while the row said 1. `alertCounts` is kept as the narrower true fact rather than repointed (renaming discipline, #1055). The caller refuses to count a window whose month keys did not all parse INTO THE EXPECTED SHAPE (an unread — or `null`/`[]`-valued — month would be a fabricated quiet day), each month in its own try so a corrupt previous month cannot disable the rest of the month and reads the suppression list via `…OrNull` so a KV blip aborts instead of freezing pre-suppression counts into a permanent row; `previousPeriod` does the month arithmetic as strings because `setUTCMonth(-1)` overflows on the 29th-31st and would have silently read the wrong key. The backfill pass is retroactive but scoped to the current month's key, and is isolated so a throw in it never costs today's row. **The cron disambiguates `null` from `0` before calling**: an absent `referral:out` key = nobody clicked (`0`); a throw or malformed value = unreadable (`null`); `webhookCounts.discord` stays `0` when the listing throws, so a separate `subscribersSnapshot` carries `null` instead. `recordGrowthDaily` **skips the write when its own read throws** rather than reseeding — the key is a no-TTL accumulator with no recovery path, and collapsing a transient 5xx to "absent" would rewrite the month as a single row. `buildGrowthDailyRow`/`appendGrowthDaily`/`parseGrowthSeries`/`recordGrowthDaily` pure-ish + unit-tested; idempotent by date (the catch-up cron re-runs a day). Stores the subscriber SNAPSHOT, not only the #548 delta, because a delta is comparable only against the day before it while a snapshot supports any window. Isolated try-guard: a failure here never aborts the Discord report
    referral.ts # #842 — consent-free outbound-referral counter. The is-down "Open ↗" wedge (renderFallbacks) fetch-keepalive POSTs `{from,to}` to `POST /api/referral` on click (delegated listener, outside the gtag/consent guard); `recordReferral` increments `referral:out:{date}` KV (read-modify-write, 2d TTL) → daily-summary `🔗 Outbound Referrals` (`formatReferralLine`). GA's `outbound_fallback_click` is the consent-gated floor; this is the honest count for the Rung-1 sponsor evidence. `parseReferralBody` rejects any `to` not in SERVICES (abuse guard). Pure fns unit-tested
    outage-audience.ts # #842-B Deliverable B — consent-free outage-moment AUDIENCE snapshot (근거 ①, sibling to referral.ts). The is-down page fires a consent-free page-load beacon (renderDelegatedListeners, outside the gtag guard) `{svc,ref(host),utm,active}` → `POST /api/pageview`; `parsePageviewBody` validates svc∈SERVICES + `classifyReferrer(utm,refHost)` folds inbound to `x`/`search`/`feed`/`owned`/`direct`/`plugin` (X app strips the referrer, so utm_source — from #842-B slice-1 shares + operator X_UTM — is the primary X signal). **#1055** split the catch-all into the full 9-bucket vocabulary: `reddit`/`hn` (by utm OR referrer host) + `refhost` (saw a host, don't name it), plus self-referrals (`ai-watch.dev`/`*.vercel.app`/`localhost`) folded to `owned` — is-down pages cross-link each other, so without that our own navigation reads as a large unidentified EXTERNAL channel. `direct` therefore now means NO referrer at all. Before this the large majority of inbound sat unclassified in `direct` (the issue measured 83% over 2026-07-13→17) and the #887-vs-#270 channel question was undecidable from data. **Precondition:** `refHost` must be a bare hostname — every host pattern is `$`-anchored, so a raw `document.referrer` URL would silently degrade every bucket to `refhost`; pinned on both sides (`api/__tests__/is-down-render.test.ts` asserts the beacon's `new URL(...).hostname`, `outage-audience.test.ts` asserts a full URL is NOT `reddit`). Vocabulary widening is **forward-only** (no backfill), so a window spanning the deploy mixes two vocabularies and its `direct` is not comparable across the boundary; `growth:daily.audienceBySource` is deliberately `Record<string,number>` so old rows stay readable, which also means widening `AudienceSource` produces NO type error there — the exhaustive `Record<AudienceSource, …>` sites `tsc` DOES catch are `daily-summary.ts`'s `AUDIENCE_LABEL` and `outage-audience.ts`'s `zeroBySource()`. **Adding a bucket needs a THIRD edit `tsc` does NOT catch — `AUDIENCE_SOURCES` itself** (a plain array): omit it and `parseOutageAudienceResponse` silently SKIPS that bucket's rows while `formatAudienceLine` omits it from the operator line, so it reads as a permanent zero with a green build. Pinned by the `AUDIENCE_SOURCES covers every AudienceSource` test. **#936** added: `utm_source=discord` (the alert "View on AIWatch" link) → `feed`; `utm_source=extension`/`statusline` (our always-on client surfaces) → `owned` — closing the UTM leaks that collapsed those clicks to `(direct)`. Full UTM-source taxonomy: [docs/reference/ga4-events.md](ga4-events.md). `recordOutageView` writes ONE WAE point (NOT KV — a viral-outage view spike would burn the write budget; mirrors api-traffic.ts #518/#548, index `isdown-view`, blob=source/`active`|`clear`/svc). Daily cron `queryOutageAudience` (AE SQL, `phase` alias — `window` is reserved) → `formatAudienceLine` `👥 Outage Audience` (active-outage subset by source + total). `active` = SSR-time down/degraded (≤60s edge-cache skew, approximate by design). Pure fns unit-tested; full beacon→WAE→SQL loop is production-gated
    probe.ts    # Health check probing — direct RTT measurement (33 probe targets: 31 API services incl. twelvelabs + kimi #1067, plus cursor #883 + characterai #921 — the latter an APP probed on its backend neo.character.ai/health after its Statuspage died #689/#800; app-category, so it stays OUT of the Latency ranking/chart (`s.category !== 'app'` guard in Latency.jsx) but shows a probe-backed RTT on its detail card). PROBE_INHERIT/resolveProbeId: Claude Code→claude, Codex→openai inherit the parent's ProbeSummary for the Score's Responsiveness — they run on the already-probed api.anthropic.com/api.openai.com so a separate network probe would be redundant. Surfaced as `probeInheritedFrom` on the /api/status service (from PROBE_INHERIT), which the ServiceDetails latency card reads for a THIRD state (`latencyCard.js` `latencyCardState`): direct probe (own endpoint, blue) / **inherited** (shows the PARENT's current RTT labeled "via <parent>", teal — instead of a contradictory "Not provided") / status-page. Inherited services stay OUT of the Latency ranking (not in probeServiceIds) — the distinction is detail-card-only
    probe-archival.ts # Daily probe RTT archival + 7-day summary (p50, p95, cvCombined)
    platform-monitor.ts # Status page platform health monitoring (metastatuspage.com for Atlassian)
    detection.ts # Detection Lead entry parsing + incident-aware reset logic
    # (#679) detection-lead-log.ts was REMOVED — the "detection lead" (faster-than-official) metric was structurally null (status-page polling is always later than the official publish; #464 already retired the framing). The KEPT RTT-degradation classifier `classifyDegradation` moved into daily-summary.ts (next to `formatDegradationSection`); `detected:{svcId}` + the MTTD framing + RTT-degradation detection (probe-degradation:monthly, #511) are unchanged. detection.ts (the `detected:` capture) is separate + kept.
    alert-feed.ts # Canonical per-user alert feed (#475) — cron appends each operator embed it sends to `alert:feed:recent` KV; `/api/status` surfaces it as `alertFeed` so the dashboard relays byte-identical alerts to a visitor's own Discord webhook (kindFromKey, svcIdsForAlert, buildFeedEntry, appendAlertFeed, readAlertFeed)
    suppression.ts # #904 — operator incident-suppression layer, ORTHOGONAL to `incidentExclude` (which is source attribution). Hides a CORRECTLY-attributed incident for a policy reason (e.g. OpenAI FedRAMP — gov-compliance-scoped, not general-API) from the live list + Score + monthly accumulator + rebuilt archives, no deploy + reversible. Single `incident:suppressions` KV list, entries scope `incident` (one id) or `service-pattern` (title match per svc, e.g. `{openai, fedramp}`). Applied as a SEPARATE layer AFTER `filterIncidents` at two points: `fetchAllServices` return (live/score/accumulator/cache) + `buildMonthlyArchive` build-time (rebuild-safe via `filterSuppressedFromMonthly`, aggregates recomputed). Badge unaffected (runs post-status-determination). Managed via `GET/POST /api/admin/suppress` (X-Admin-Key) + `scripts/suppress-incident.mjs`; pure fns (isSuppressed/applySuppressions/mutateSuppressions/normalizeSuppressions) unit-tested. See [docs/reference/operator-tools.md](operator-tools.md) + [status-determination.md](status-determination.md)
    indexnow.ts # #887 SEO freshness — on a status-change edge the cron `pingIndexNow`s the affected `is-{slug}-down` URLs to api.indexnow.org (fans to Bing/Yandex/Naver/Seznam — Naver = KR; Google ignores IndexNow so this COMPLEMENTS its crawl). Fire-and-forget + isolated try-guard (never affects the alert path). Reuses `isDownUrl` (drops the bedrock/azureopenai `#hash` fallbacks — no crawlable page). Ownership key hosted at `public/{INDEXNOW_KEY}.txt`. Pure `buildIndexNowBody`/`indexNowUrlsFor` unit-tested
    ext-claude.ts # `?src=ext-claude` lite projection (#837) for the Claude-only Chrome extension — `isExtClaudeRequest` (exact tag, checked BEFORE statusline) + `buildExtClaudePayload` emit ONLY the 3 Anthropic surfaces (claude/claudeai/claudecode). **PR1**: `{id,name,status,score,grade,fallback[]}`. **PR2 (projection v2)**: also per-surface `incidents[]` (ACTIVE only — resolved/monitoring excluded — `{id,title,status,impact,aiSummary?}`, aiSummary from `ai:analysis` for active Claude incidents) + `reports{count,recent[]}` (the GATED #575 crowd map via `buildReportFeedMap`, free-text `desc` dropped) — so the popup shows what's actually happening, not just a color. ~0.6 KB vs ~780 KB full. Scores the full set (getFallbacks candidate pool, per-category api→api/app→app/agent→agent) but narrows the emit to 3 (+ injects reportFeed/aiSummary maps the handler resolves from KV). The handler serves it from an in-worker `caches.default` edge cache (canonical key, `s-maxage=60`) + WAE-tags `ext-claude`; true zone-level request elimination is gated on a custom Worker subdomain (#439)
    reddit.ts   # Reddit r/ChatGPT + r/netsec + r/cybersecurity monitoring
    security-monitor.ts # AI service security monitoring (HN Algolia, OSV.dev SDK vulnerabilities — 24 tracked packages across PyPI + npm including Langchain ecosystem adapters, see OSV_PACKAGES; two-phase flow: querybatch bulk scan + per-vuln GET enrichment, capped at OSV_MAX_DETAIL_FETCH=15/cycle to protect the Workers subrequest budget; overflow re-offered next cron since seen-markers are only written for surfaced alerts). **#720 — HN Algolia treats `query` as plain all-words-AND text, NOT boolean** (the old `("ai" OR …) AND ("breach" …)` returned 0 hits for its whole lifetime — it searched for the literal words "OR"/"AND"). `buildHNQuery` now emits the AI keyword set + `optionalWords` (the OR knob) and `titleMatchesAiSecurity` applies the real `(AI keyword AND security keyword)` precision filter client-side with **word-boundary** regex (substring matched `rce` in "sou**rce**" / `leak` in "**leak**ed financials" — ~80% noise). Both exported + unit-tested. **#892 — two-source confidence split**: OSV = confirmed CVEs; HN = unverified community chatter matched by keywords. (1) **Public-exposure gate** — `isPubliclyVerifiedAlert(meta)` (OSV, OR HN whose title carries an explicit `CVE-YYYY-NNNN` via `CVE_ID_RE`) gates every PUBLIC surface: `readRecentSecurityAlerts` (→ `/api/status[/cached]` `securityAlerts` → dashboard ServiceDetails per-service card, filtered THEN capped at 20; **#950** removed the Overview aggregate banner — re-surfacing deferred pending #949 first-party CVE data so lexicographically-first `security:seen:hn:*` can't displace OSV) AND the monthly archive (`buildMonthlyArchive` filters `security:monthly` before `summarizeSecurityAlerts` → `/api/report` reports site). Unverified HN chatter → **operator Discord digest ONLY** (`detectSecurityAlerts`→`formatSecurityDigest`, never routed through the reader, so operators keep full triage visibility). (2) **HN precision** — `titleMatchesAiSecurity` split STRONG vs WEAK security keywords (`leak`/`unauthorized` need a `HN_DATA_ACCESS_CONTEXT` word — drops model/product "leaks" + lawsuit/billing "unauthorized"), + `HN_TITLE_VETO` (legal/speculation: sues/lawsuit/alleged/rumor/possible) + `HN_NAME_COLLISION_RE` (crypto-EXCHANGE "Gemini" via coinbase/binance/`crypto…exchange`, mouse "cursor position/overlap" — NOT bare "crypto"=cryptography). A 6-yr corpus audit: 165→126 kept (39 dropped ≈ all noise, 0 high-value CVE findings lost). All pure + unit-tested. **#949 — third source: NVD first-party product CVEs.** OSV only covers *package* deps, so it misses 100% of CVEs in the AI vendors' OWN products (Claude Code, OpenAI Codex, ChatGPT app, Azure OpenAI, Gemini, Grok, Perplexity Comet) — exactly the "security" signal users expect on a service card. `fetchNvdAlerts` runs ONE `lastModStartDate..lastModEndDate` query/cycle over a FIXED 6h rolling window (NO cursor — deliberately mirrors OSV's cursorless rolling-window + seen-marker pattern; a cursor advancing to `now` inside the fetcher would make NVD *consume-once* and silently lose CVEs if the caller's Discord send throws before the seen-markers are written, since index.ts sends-then-marks), then filters client-side — no `NVD_API_KEY` needed (1 req/hr ≪ 5/30s unauth limit), self-dedups vs seen-markers like OSV. The window is small because NVD's response time is super-linear (6h≈0.36MB/1.5s vs 24h≈7.8MB/51s → a wide window times out); an undelivered CVE stays in-window ~6 hourly retries. Three noise classes are filtered by pure predicates: `isRejectedCve` (withdrawn CVEs), `isThirdPartyCloneSubject` (3P tools that merely NAME a first-party product — `claude-code-router`, `AgentAPI`, `MCP Manager for Claude Desktop`, WordPress "ChatGPT" plugins, `LibreChat`), `isAiCreditedOssPatch` (kernel/u-boot patches merely CREDITING an AI tool). Measured on live NVD: **37 → 30 findings, all 7 known false positives dropped**. **Every 3P marker is ANCHORED, never a bare noun** — the predicate tests the WHOLE description, so a bare `\bplugin\b` silently dropped CVE-2025-52882, the feature's own flagship finding (its real text reads "For Jetbrains IDE **plugins**, Claude Code [beta]…" — Claude Code ships AS a JetBrains plugin, #920), and a bare `<noun> for` would drop "client for macOS" though `claude for windows` is a first-party product name in `NVD_FIRST_PARTY` itself. A dropped CVE is invisible in production, so each rule carries a keeps-genuine test and the `claudeCodeCve` fixture is held faithful through the "Jetbrains IDE plugins" sentence — a truncated fixture is what hid the bug (cf. #1021). `matchNvdFirstParty` is the attribution gate: `NVD_FIRST_PARTY` strong multi-word phrases match alone; weak single tokens (`grok`/`gemini`/`codex` — each also a generic word) require a vendor context marker. `nvd` source is CVE-backed → `isPubliclyVerifiedAlert` treats it like OSV (always public). Dashboard routing: alert carries a `service` label mapped by `NVD_SERVICE_MAP` in src/utils/securityAlerts.js (mirror of `NVD_FIRST_PARTY`, cross-layer sync-tested; Claude Desktop→claudeai as there's no dedicated desktop card). All pure fns unit-tested; the #950-removed Overview banner stays removed (v1 relies on the ServiceDetails card)
    parsers/    # Platform-specific parsers (statuspage, incident-io, gcloud, aistudio, instatus, betterstack, aws, flashduty)
                # flashduty.ts (#618/#619/#1171): normalizes the browser-rendered DeepSeek Flashduty feed (status.deepseek.com is bot-walled to a plain fetch) → ServiceStatus; parseFlashdutyFeed({primaryComponentId}) scopes deepseek (API components, worst-of) vs deepseekapp (chat/app components, worst-of). KV deepseek:feed, 3h TTL. Full pipeline: docs/reference/data-flow.md
                # aws.ts (#677): Bedrock uses parseAwsHealthEvents on the AWS Health public events JSON API (health.aws.amazon.com/public/events — plain fetch, no scrape; utf-16, decoded via BOM-detection in services.ts). The JSON gives ONE event per incident with real startTime+endTime (epoch ms) → correct duration + single record, replacing the legacy per-region RSS whose per-update-epoch guid split active↔resolved into two records and floored resolved durations to 1m. impact from typeCode (OPERATIONAL_ISSUE→major), but **#707**: `awsHealthImpact(typeCode, text)` also reads the EVENT_LOG text — a clear NON-reliability advisory (compliance/export-control/`revoke access`/deprecation/scheduled, `NON_RELIABILITY_RE`) with NO outage signal (`OUTAGE_SIGNAL_RE`) → `null` (informational, excluded from the Score) so a model-access/compliance event (e.g. the 2026-06 "Fable 5 / Mythos 5 Access" export-control revocation that scored Bedrock 43) doesn't read as an outage; default stays major, outage signal wins. azureopenai still reuses the same RSS parser on the Azure RSS (its feed already yields correct durations — the 1m bug was AWS-RSS-specific), now via **`parseAwsRssIncidentsResult`** (#1212) — the bare item parser is module-private because `[]` alone cannot separate a quiet feed from a body that is not a feed; `parseAwsHealthEventsResult` is the same gate for the Health JSON. **#574**: `parseAwsRegionHealth(json)` reuses the SAME Bedrock fetch (no extra subrequest) to derive currently-degraded AWS INFRA regions (all AWS services EXCEPT `BEDROCK` — avoids circular self-signal) → attached to `bedrock.ServiceStatus.awsRegionHealth`. Consumed by `supply-chain.ts` `buildSupplyChainBanner` (supply-chain correlation banner: AWS region degraded + a dependent AI service degraded AND naming **that same degraded region** in its own incident, StatusGator-style; `SUPPLY_CHAIN_AWS_DEPS` curated map — Together excluded as own-cloud) → `/api/status` `supplyChainBanner` field → dashboard `<SupplyChainBanner>` + is-down note. **#1000**: the cross-check is REGION-AWARE (`awsRegionsNamedByService` ∩ `awsRegionHealth`) — the original "does the incident mention AWS at all?" regex let the banner pair a me-central-1 infra event with Pinecone's us-east-1 incident (prod, 2026-07-13); Bedrock's auto-attribution shortcut is gone (its AWS-feed incidents carry `componentNames: [region]` like anyone else's), `regions` shows only the correlated regions, and an AWS `global`-only event (Route 53/IAM/CloudFront) deliberately never correlates. Full rules in [docs/reference/status-determination.md](status-determination.md)
                # incident-io.ts component tagging: the Statuspage-compat API returns `components: []` on EVERY incident (verified 0/25 openai, 0/14 jetbrains, 0/25 langsmith, 0/25 langfuse), so the incident→component mapping is recovered from the page HTML's `component_impacts` (`status_page_incident_id` → `component_id`; the incident id joins to the v2 API's). TWO consumers, deliberately split by axis: `attachIncidentIoComponentNames` (#1004) resolves ids→NAMES for junie's `incidentComponents` name-allowlist, while `attachIncidentIoComponentIds` (#1032) writes raw ids to `Incident.componentIds` for `filterIncidents`' id-keyed exclude-bypass. The split is NOT redundancy: status.openai.com has two components both named "Login" (APIs group → openai, ChatGPT group → chatgpt), so names cannot disambiguate them and only ids can. #1032 must never write `componentNames` — its emptiness is what `filterByComponentStatus` (#970) reads as "untagged → drop", so tagging page-wide would silently flip langsmith/langfuse. Both run BEFORE filterIncidents (#940) and both warn loudly when tagging yields nothing, but they degrade to OPPOSITE things — #1004 losing its tags drops EVERY junie incident (total loss), #1032 losing its tags reverts to pre-#1032 behaviour (the bypass just never fires). Same warn, very different blast radius
                # statuspage.ts `resolveComponentNames` (#1047): a provider can UNLINK every component from an incident AS IT RESOLVES (Anthropic's 2026-07-16 `kqbd7wm6hnnr` went out tagged with 4 components and resolved with `components: []`; of its 49 siblings 43 kept theirs, 2 were likewise unlinked, 4 were never linked — per-incident behaviour, not a page change). For a service whose attribution rides on componentNames — claudeai/claudecode, whose titles carry no `incidentKeywords` token — the attribution then evaporates at the exact moment of resolution: they alerted on it, then dropped out of the resolved alert (`🔴 Anthropic (Claude API, claude.ai, Claude Code)` → `🟢 Claude API`), off their dashboard cards, and out of `incidents:monthly`. So the names are recovered from `incident_updates[].affected_components` when — and ONLY when — the live list is empty: the SAME empty-only rule as `attachIncidentIoComponentNames` above, now the house convention for this field (keep the two in step). NOT a union — `filterIncidents`' keyword match reads these names on every incident, so a union would broaden attribution page-wide (#361). **Does not contradict the "#1032 must never write componentNames" rule above — but only BY DATA, not by construction**, the very distinction #1032's own "non-regressive by construction, not by luck" language draws. That rule protects incident.io services, and they flow through this same `parseIncidents`; recovery is inert there solely because incident.io empties `affected_components` exactly as it empties `components` (**present on 0 of 226 incidents across all 10 of its hosts**, checked 2026-07-17). So this is a SECOND route into #1032's "Known gap" — see status-determination.md. Measured blast radius: **22 of 865 live incidents across the 24 statuspage-compat pages (14 Atlassian + 10 incident.io; 23 reachable — character.ai is the deactivated #800 source)** — claude (3) + cerebras (19), nothing else; whether cerebras' 19 are unlinks at all was NOT investigated (it populates `components` on only 12 of 45 incidents). **Live filtering delta: claudeai +2, claudecode +3 — and nothing else** (claudeai already matched the third by title; claude/cerebras outputs unchanged). Not non-regressive in principle, though: componentNames' EMPTINESS is itself a signal, so recovery reclassifies these as tagged for every reader of it — but all 22 are `resolved` (the unlink happens AT resolution), so `includeUntaggedIncidents`' valve and #970's untagged-`impact:none` branch, both gated on a non-resolved status, have ZERO live instances and are each pinned only prophylactically (synthetic fixtures `coworkonly1` / `activenone1`). Rules + residuals: [docs/reference/status-determination.md](status-determination.md)
                # incident-io.ts `parseIncidentIoGlobalPage` (#1066): incident.io's newer "global"/multi-region status pages (LangSmith migrated 2026-07-15: status.smith.langchain.com 301s to global.status.smith.langchain.com/gcp-us) serve `components: []` from the WHOLE v2 compat API (summary.json/components.json/status.json — not just `incidents[].components` as in the #1004 note above), so the live data exists ONLY in the page-root RSC (`self.__next_f` pushes). This adapter reconstructs a summary.json-shaped `StatuspageResponse` from that RSC — the component catalog (`"component":{…}` → id/name, default operational, degraded by any ACTIVE incident's `affected_components` current_status; incident.io→Atlassian status vocab, full_outage→major_outage which normalizeStatus needs), the overall indicator, and the incident list as `incident_links` (FULL ~90-day history) ∪ `incidents` (recent feed, the only one with live current_status + timeline text), deduped by id and joined with `component_impacts` for each incident's window + components. Outage window = earliest impact start → latest impact end (NOT `published_at`, which incident.io can stamp AFTER a short impact ended → negative duration; also matches computeIncidentIoUptime's window). Maintenance dropped. Returns null (→ caller withholds, #713) when no component catalog. Opt-in via `incidentIoGlobalPage: true` in services.ts; fetchService rebuilds summaryData/rawIncData from uptimeHtml before the incidents parse, so resolveSvcStatus / parseIncidents / calendar / #135 miss-tracker all run unchanged. Uptime unaffected (already reads the same RSC).
                # dailyImpact support: statuspage (uptimeData), incident-io (component impacts), betterstack (status_history from index.json)
                # impact-weights.ts: shared MAJOR_WEIGHT=1.0, MINOR_WEIGHT=0.3 — used by statuspage.ts (official uptime%) and score.ts (incident-component weighting) for Atlassian-aligned weighting (incident-io.ts's estimate path was removed in #713)
                # aistudio.ts (#310): parses aistudio.google.com/status MakerSuite gRPC-web JSON. API key + Referer gated; component filter at source, widened **#1012** to `[API=1, MULTIMODAL_LIVE=2]` (was API-only — a pure Multimodal Live outage tagged `[2,3]`, no `1`, used to be silently dropped; AI_STUDIO=3, the web IDE, stays excluded — not an API surface). Merged via mergeAistudioIncidents() in services.ts with vertex:/aistudio: ID prefixes; filterIncidents() bypasses incidentKeywords for aistudio: IDs since they're already component-scoped. **#1012** also stamps the surviving component enum(s) onto each returned incident as `componentIds` (stringified, mirrors incident-io.ts's `attachIncidentIoComponentIds`), which `synthesizeAistudioComponents` reads to derive a 2-row `ServiceComponent[]` (API / Multimodal Live API) — worst-of that component's currently-active incidents, mirroring `deriveAwsStatus` (aws.ts) exactly incl. its "active decided by status alone, not impact" rule; `'major'` (severity 2) is the escalation-to-`down` threshold here, not `'critical'` — aistudio's `mapImpact` never produces `'critical'`, only `'minor'`/`'major'`/`null` — since aistudio has no dedicated component-status endpoint. Wired into gemini's return object the same way betterStack/instatus set `components`, so both the dashboard `ComponentBreakdown` and the `is-down` Edge template (`api/_is-down/html-template.ts`, #604/#606 shared field) render it. No same-cycle trust gate — a same-cycle gate was tried and reverted during review (3 rounds: `merge.held > 0` flapped the breakdown on every transient failure during an ordinary healthy day, since carry-over drops resolved incidents; "was gemini ever cached" was then found to be true forever once cached, since `cacheWrite` re-caches every service unconditionally every cycle regardless of aistudio's own outcome — neither signal distinguishes a fresh failure from a week-old one, and a real fix needs a NEW persisted last-successful-read timestamp, out of #1012's scope). So a failed read with nothing fresher just falls back to whatever `filtered` already holds — the SAME accepted limitation `aistudioDailyImpact` above already has (a multi-day outage past the #717 24h carry-over cap reads as a stale "operational" breakdown, not an explicit unknown — `ServiceComponent.status` has no unknown state). Not a new class of wrongness: gemini's badge is ALSO vertex-only-derived on a failed aistudio read, so the breakdown asserts nothing the badge doesn't already assert from the same incomplete data. **#717**: the gated source is intermittent, and a failed read (threw/non-OK/unparseable) used to silently drop to vertex-only — which made a Gemini incident flap in/out of the dashboard per refresh (each `/api/status` re-fetches live, so one cycle's failed aistudio read dropped the active incident & flipped the badge operational⇄degraded). Now on a failed read the merge **holds the last-known ACTIVE aistudio incidents** from `services:latest` (`carryOverAistudioIncidents`, lazy KV read only on failure, age-capped 24h via `AISTUDIO_CARRYOVER_MAX_AGE_MS`); a SUCCESSFUL read stays authoritative (fresh set incl. resolution, carry-over unused). ServiceDetails surfaces BOTH upstream sources as links (AI Studio + Google Cloud, `STATUS_SOURCES`).
extension/         # Claude-only Chrome extension (MV3, #837 PR2). Standalone static bundle — consumes the worker `?src=ext-claude` projection ONLY; reads NO page content (host_permissions = worker origin only → "zero data collection"). Excluded from the Vercel build via `.vercelignore`.
  manifest.json    # MV3: action(popup), module service-worker, permissions [alarms,storage], host_permissions [worker origin]. NO tabs/content-script/<all_urls>
  config.js        # WORKER_BASE/SITE_BASE + derived STATUS_URL/REPORT_URL/IS_DOWN_URL + POLL_PERIOD_MINUTES(2). Local-verify toggle documented inline
  service-worker.js # chrome.alarms(2min)+install/startup → poll `?src=ext-claude` → worstStatus → always-on color-dot badge (🟢/🟠/🔴/grey) → chrome.storage; onMessage 'refresh' for popup-open instant fetch. Ephemeral-safe (state in storage, alarms not setInterval)
  popup.html/.js/.css # CSP-clean (external module, no inline). Instant paint from storage + fresh fetch. Per-surface card: status·Score·active incidents(+AI summary)·gated crowd reports·fallback. One-click "Report an issue" → POST /api/report-issue. DOM via createElement+textContent (injection-safe)
  lib/render.js(+.test.js) # PURE (no chrome.*): worstStatus/badgeFor/formatScore/formatRelTime/hasLiveIssue/labels — vitest via `npm run test:ext` (root vitest include). Web Store prep: CHROMEWEBSTORE.md + PRIVACY.md. Icons generated by scripts/generate-extension-icons.mjs (sharp, from public/icon-512.png)
.claude-plugin/     # marketplace.json (#920) — AIWatch's OWN plugin catalog at the repo root, the file that makes `plugin/aiwatch/` installable: `/plugin marketplace add bentleypark/aiwatch` + `/plugin install aiwatch@aiwatch-dev`. Marketplace slug is `aiwatch-dev`, a namespace SEPARATE from the `aiwatch` plugin slug (`aiwatch@aiwatch` reads like a typo) — and `claude-community` is RESERVED upstream, so it can never be our self-published slug. `source: ./plugin/aiwatch` resolves the subdirectory (verified end-to-end: `claude plugin validate` → `marketplace add <path>` → `install` → `details` resolves the /aiwatch skill). **Why this exists**: the plugin was submitted to `claude-community` on 2026-07-08, but that review has NO published SLA, no status API, and no reviewer contact channel — so it can never gate whether users can install. (Corroborating but anecdotal, from the operator's own plugin dashboard rather than anything in-repo: the sibling `claude-code-mobile-spine` submission, structurally correct, was still pending ~7 weeks in. The no-SLA/no-contact facts stand on their own.) The community listing is now a discovery bonus layered on top (`PLUGIN_MARKETPLACE_URL` in `api/_shared/plugin-cta.ts`, empty until approved, ADDS a link rather than turning the CTA on). **Release rule**: `plugin.json`'s `version` PINS the release — users only receive updates when that string changes, so **bump it whenever `plugin/aiwatch/**` changes** or the fix ships to nobody. Keeping an explicit `version` is deliberate: omitting it falls back to the git commit SHA (repo-wide, NOT scoped to the plugin subtree), which in a MONOREPO means every unrelated SPA/worker commit reads as a new plugin release. Note the marketplace entry deliberately omits `version` — upstream resolves `plugin.json` first and silently ignores a marketplace-entry `version`, so setting both invites a phantom disagreement; `plugin.json` is the one place it lives. The bump is currently unguarded (no CI check, and `git-mutation-gate.sh`'s docs-drift map doesn't cover `plugin/`) — a known, accepted gap; this rule is the only thing enforcing it. `plugin-page.test.ts` drift-pins the published install command against this catalog (slug match, `source` resolves to a real manifest, slug not reserved) — the bug it exists to catch is the original one, where the page published `aiwatch@claude-community` while the repo shipped no marketplace.json at all, so no command on the page resolved.
plugin/aiwatch/     # Claude Code plugin (#920), served by the `.claude-plugin/` catalog above. Standalone bundle — consumes the worker `/api/statusline/:preset` (#918) ONLY, reads no code, collects no data. Excluded from the Vercel build via `.vercelignore`. Two surfaces (statusLine BAR is impossible via a plugin — plugin settings.json only supports agent/subagentStatusLine): a **background outage monitor** (`monitors/monitors.json` → `bin/aiwatch-monitor.sh`, declared under `experimental.monitors`; polls `GET /api/statusline/down` — parseable UNCAPPED `status\tname` list via `renderStatuslineDownList` — on a 60s loop and DIFFS poll-over-poll to emit explicit per-service transitions `🔴 <name> is down/degraded` / `✅ <name> has recovered` via `comm`; startup-healthy is silent, fail-silent on network error, diff-by-name so a severity shift isn't re-alerted) + an **`/aiwatch` command** (`commands/aiwatch.md` → `bin/aiwatch-status.sh`, on-demand INCIDENT briefing via `GET /api/statusline/brief` — each down/degraded service + active incident title/impact + AI summary + fallback + a **short landing link** `ai-watch.dev/p/{slug}` (`SERVICE_ID_TO_SLUG`), server-rendered `renderStatuslineBrief`, a text generalization of the ext-claude projection). The link is a bare short PATH (no `?utm` query — survives the model relay + terminal that would drop a long query); a **`vercel.json` `redirects` rule** `/p/:slug → /is-:slug-down?utm_source=claude-code&utm_medium=plugin&utm_campaign=outage` (config, **zero Serverless Functions** — deliberately avoids the 12-fn cap #862) 307s to the real is-down page WITH the UTM, so GA4 attributes plugin inflow AND the #842-B consent-free outage-audience metric folds it to a `'plugin'` bucket via `classifyReferrer(utm_source='claude-code')`. Both scripts honor `AIWATCH_BASE`/`AIWATCH_POLL_SECONDS` for self-hosters. **Adoption read-back** (#920, mirrors #918): the monitor/brief endpoints WAE-tag `aiwatch-monitor`/`aiwatch-brief` (NOT `statusline-*`), read back daily by `queryPluginTraffic` → `🧩 Plugin (Claude Code)` daily-summary section (monitor polls + /aiwatch briefings; consent-free adoption proxy, not a user count). Verified via `claude plugin validate` + shell transition tests; the `claude-community` submission (platform.claude.com/plugins/submit) is operator-manual and is NOT the install path — see `.claude-plugin/` above.