# GSC Custom-Domain Index Visibility — Design Spec

**Status:** Approved through brainstorming, ready for implementation plan
**Date:** 2026-05-15
**Author:** Tim (timothy@aimclear.com), with Claude
**Extends:** [`2026-05-12-gsc-indexing-design.md`](./2026-05-12-gsc-indexing-design.md) (the shipped GSC indexing system, PR #849, branch `feature/gsc-indexing`)

## Problem

The shipped GSC system auto-creates a Search Console **URL-prefix property per custom domain**, verified by meta tag and owned by the **service account**. A blocking question stalled rollout: _won't auto-creating hundreds of custom-domain properties flood our existing Search Console account?_ — and, relatedly, if those properties are invisible to us, _how do we confirm custom domains are actually getting indexed?_

This spec resolves four decisions and specifies the one new piece of work they require.

## Decisions

### 1. Account topology — no separate account, no sharding

Custom-domain properties are created and owned by the **service account**, not any human Google login. Properties created via the Sites API by a service account [do not appear in a human's Search Console UI](https://support.google.com/webmasters/thread/87959428) unless that human account is explicitly added as an owner. So the user's existing Search Console account (holding the 5 hand-created `sc-domain:` / URL-prefix properties) is **not** flooded.

The only real ceiling is Google's [**1,000 properties per owner**](https://support.google.com/webmasters/thread/177011340) limit (verified current, 2026), which applies to the service account:

```
5 fixed properties (4 sc-domain: + amsoil.aimclear.com)
+ ~200 active custom-domain URL-prefix properties      (current scale, slow growth)
= ~205 / 1000
```

Active custom domains would have to ~5x before this is a conversation, and churned domains are reclaimed by the existing `delete_property` job (count tracks _active_, not lifetime). A separate Google account / second service account / property sharding is rejected as premature: it adds zero Indexing API quota (per GCP project) and zero URL Inspection quota (per property) — its only lever is another 1,000 bucket we are nowhere near needing.

**No code change.** The shipped property strategy stands.

### 2. Custom-domain property visibility — invisible by design

We will **not** delegate ownership of custom-domain properties to any human Google account. They remain service-account-owned and therefore absent from Google's native Search Console web UI. This was implicit in the shipped design; it is now an explicit, recorded decision. Admins observe custom-domain indexing exclusively through our own admin surfaces (the `/admin/gsc` dashboard and the per-dealer detail modal), never through Google's UI.

### 3. Verification surface — at-a-glance only; alerting deferred

Because custom domains are invisible in Google's UI, admins need an in-app way to confirm they are getting indexed. The authoritative signal already exists and is already collected: the **URL Inspection API** (`lib/google/url-inspection.ts`) is queried by the daily audit for every active dealer's canonical homepage, writing `gscLastIndexStatus` / `gscLastInspectedAt` per dealer. Quota is 2,000/day **per property**; one homepage inspection/day/custom-domain is negligible.

The gap is _surfacing_, not collecting: that data currently lives only in the per-dealer detail modal (one dealer at a time). We will add an **at-a-glance rollup card** to `/admin/gsc` that aggregates it for the custom-domain cohort.

**Explicitly deferred until the base mechanism is proven in production:** all proactive alerting — both a "stale not-indexed custom domains" email anomaly and a "property count approaching cap" email anomaly. We add the _visibility_ now and decide on _alerting_ later, once we trust the data.

### 4. Bucket model — 4 buckets, 14-day settling cutoff

A raw "not indexed: N" count is misleading because Google legitimately takes days-to-weeks to index a fresh site. The rollup classifies each active-custom-domain dealer into exactly one of four buckets. The predicates are evaluated **top-down, first match wins**, and the final bucket is a catch-all, so the classification is total (every dealer lands in exactly one bucket — no gaps):

| Order | Bucket                     | Predicate                                                                                                                                                 | Treatment                                                                                                            |
| ----- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| 1     | **Never inspected**        | `gscLastInspectedAt IS NULL`                                                                                                                              | Count + collapsible list. New activations the sweep hasn't reached, or — if it grows — a sign the audit is stalling. |
| 2     | **Indexed**                | `gscLastIndexStatus = 'indexed'`                                                                                                                          | Count only. Healthy.                                                                                                 |
| 3     | **Not indexed · settling** | `gscLastIndexStatus = 'not_indexed'` **AND** `gscLastSubmittedAt` is set **AND** `gscLastSubmittedAt ≥ now − SETTLING_DAYS`                               | Count only. Expected; Google hasn't crawled it yet. Not alarming, not listed.                                        |
| 4     | **Needs attention**        | everything else that reached this rule (`error` any age; `not_indexed` with null or stale `gscLastSubmittedAt`; inspected-but-null-status data anomalies) | Count + list, **expanded by default**. The only actionable bucket.                                                   |

`SETTLING_DAYS = 14`, defined as `CUSTOM_DOMAIN_INDEX_SETTLING_DAYS` in `lib/gsc-constants.ts`.

Making **Needs attention** the explicit catch-all (rather than a positive predicate) is deliberate: the one data anomaly worth calling out is "inspection ran but recorded no status" (`gscLastInspectedAt` set, `gscLastIndexStatus` null — shouldn't happen, since inspection always writes a status). A catch-all surfaces it to a human instead of silently dropping it into a gap.

The settling/needs-attention split is the entire reason the card is trustworthy instead of noise — but it is a **display threshold only**; no alerting is attached to it (per Decision 3).

## The new work

### Rollup card on `/admin/gsc`

A new `<CustomDomainIndexing>` card, rendered on the existing client page alongside `QuotaBar` / `QueueHealth`, same card styling:

```
┌─ Custom Domain Indexing ──────────────────────────── 198 active ─┐
│                                                                   │
│   ● Indexed           171      ● Not indexed · settling   14     │
│   ▲ Needs attention     9      ○ Never inspected           4     │
│                                                                   │
│   Last sweep: today 03:04 UTC                                     │
│                                                                   │
│   ▾ Needs attention (9) — submitted >14d ago, still not indexed   │
│   ┌─────────────────────────────────────────────────────────┐   │
│   │ joessoil.com      not_indexed   sub 22d   seen 6h ago  → │   │
│   │ bobsoilshop.com   error         sub 31d   seen 6h ago  → │   │
│   └─────────────────────────────────────────────────────────┘   │
│   ▸ Never inspected (4)                                           │
│                                                                   │
│   GSC properties: ≈205 / 1000                                     │
└───────────────────────────────────────────────────────────────────┘
```

- **Cohort:** dealers with `customDomainStatus = 'active'`. Subdomain dealers are out of scope here — they are covered by the 4 `sc-domain:` properties and _are_ visible in Google's UI, so they don't share this blind spot.
- **Counts** (Indexed, Settling, Needs attention, Never inspected) are read-only.
- **Needs attention** is expanded by default; **Never inspected** is collapsed by default. Each lists: custom domain, `gscLastIndexStatus`, age since `gscLastSubmittedAt`, relative `gscLastInspectedAt`, and a row affordance (`→`).
- **Row click → existing `DealerDetailModal`** (which includes the Indexing & SEO panel). `DealerDetailModal` has no section-targeting prop and renders all sections, so this is a plain modal-open keyed by `dealerId`, not a deep-link/scroll-to that section — adding section anchoring is a possible future enhancement, deliberately out of scope here. The card builds _no_ new per-dealer actions — Resubmit / Force reindex / Deep audit already live in that modal. The card is a triage lens that points admins at the handful of domains needing a human instead of making them scan ~200.
- **`Last sweep`** shows the most recent `gscLastInspectedAt` across the cohort, so admins can judge data freshness.
- **GSC properties line** (`≈205 / 1000`): a passive headroom indicator so the 1,000-property ceiling can never be hit by surprise. DB-derived: `count(customDomainStatus='active') + FIXED_PROPERTY_COUNT (5)`. Display-only, no alert (consistent with Decision 3). The `≈` signals it is an estimate from our state, not an authoritative Sites API list — acceptable at ~20% utilization; switching to an authoritative `sites.list` count is a future option bundled with alerting if/when that's built. _(Included on the strength of the "never surprised" intent from the scale discussion; flagged for explicit confirmation at spec review — easy to drop if unwanted.)_

### Data source

Extend the existing admin-guarded `GET /api/admin/gsc/summary` response with:

```ts
customDomainIndexing: {
  active: number;
  indexed: number;
  settling: number;
  neverInspected: number;
  needsAttention: number;
  lastSweepAt: string | null; // max gscLastInspectedAt across cohort
  needsAttentionList: Array<{
    // capped (e.g. 50) — full triage is the dealer table
    dealerId: string;
    customDomain: string;
    indexStatus: 'not_indexed' | 'error' | null;
    submittedAt: string | null;
    inspectedAt: string | null;
  }>;
  neverInspectedList: Array<{ dealerId: string; customDomain: string }>; // capped
}
propertyHeadroom: {
  ownedEstimate: number;
  cap: number;
} // pending review confirmation
```

Computed with Prisma aggregates/queries over `Dealer` filtered to `customDomainStatus='active'`. **No new tables, no new Google API calls, no quota impact** — pure read over data the daily audit already populates.

### Files touched (indicative; finalised in the plan)

- `lib/gsc-constants.ts` — add `CUSTOM_DOMAIN_INDEX_SETTLING_DAYS = 14`, `GSC_PROPERTY_CAP = 1000`, `FIXED_GSC_PROPERTY_COUNT = 5`.
- `app/api/admin/gsc/summary/route.ts` — add the `customDomainIndexing` (+ `propertyHeadroom`) blocks.
- `app/admin/gsc/page.tsx` — new `<CustomDomainIndexing>` component; wire row click to `DealerDetailModal` (fetches `/api/admin/dealers/[id]/detail`, which already returns gsc fields + recent jobs). Confirm during planning that `DealerDetailModal` renders standalone outside the dealer table.
- `app/admin/gsc/page.module.css` — card + bucket + list styles.
- Tests (see below).

## Testing strategy

Red-Green-Refactor, per project requirement. Each test gets a one-line comment recording what it would _falsely pass_.

**The boundary test is load-bearing** and must be written to genuinely fail an empty/naïve implementation:

- A cohort dealer `not_indexed`, `gscLastSubmittedAt = now − 13d` → **settling** (not needsAttention).
- The same dealer at `now − 15d` → **needsAttention**.
- `error` at `now − 1d` → **needsAttention** (age-independent).
- `gscLastInspectedAt = null` → **neverInspected** even if status would otherwise classify.
- Subdomain-only dealer (no active custom domain) → excluded from every count.

A naïve implementation that counts all `not_indexed` as needsAttention fails the 13-day case; one that ignores `gscLastInspectedAt` fails the null case; one that forgets the cohort filter fails the subdomain-exclusion case. These are the assertions that make the suite meaningful.

- **Summary API test** (`app/api/admin/gsc/summary/__tests__/route.test.ts`): admin-guard unchanged; the four bucket counts + `lastSweepAt` + capped lists computed correctly against a seeded mix; `propertyHeadroom.ownedEstimate` math.
- **Component test**: card renders the four counts; Needs attention expanded / Never inspected collapsed by default; a Needs-attention row click invokes the modal-open handler with the right dealerId; empty cohort renders a sensible zero-state.

## Out of scope / explicitly deferred

1. **All proactive alerting** — stale-not-indexed email anomaly _and_ property-count email anomaly. Revisit once the at-a-glance mechanism is trusted in production.
2. **Owner delegation** of custom-domain properties to any human Google account (would re-introduce the clutter we are deliberately avoiding).
3. **Separate Search Console account / second service account / property sharding** — not needed at projected scale; revisit only if active custom domains approach the 1,000 ceiling (the headroom line makes that visible).
4. **Authoritative `sites.list` property count** — DB-derived estimate is sufficient for a display-only headroom indicator; an exact count is bundled with alerting if/when that is built.
5. **Subdomain index rollup** — subdomains are visible in Google's UI and not the blind spot this addresses; could be added later for completeness.

## Rollout

No migration, no env var, no infrastructure. Ships as additional commits on `feature/gsc-indexing` (PR #849), reviewed/merged with the rest of the GSC work. Inert until the daily audit has run at least once (counts simply show everything as "Never inspected" until then — itself a useful signal that the audit hasn't run).
