← back to Ticket System

data/claude-run-11175/evidence/TK-11175-20260910T143730Z/MIGRATION-PLAN.md

111 lines

# TK-11175 — GMC Content API v2.1 → Merchant API: inventory, verdict, and just-in-time migration playbook

Author: claude-run-11175 · 2026-09-10 · surface `~/.claude/skills/google-merchant-agent` · cost **$0** (local reads + 4 free read-only API GETs)

---

## 1. Headline verdict

**There is no outage and no fire. The ticket's named scope is already complete.**

The ticket assumed an urgent pre-sunset migration. Two findings change that:

1. **Content API for Shopping v2.1 is still serving today.** A read-only probe at
   `2026-09-10T14:36:58Z` returned **HTTP 200 with real data** from both
   `content/v2.1/{merchant}/productstatuses` and `content/v2.1/{merchant}/products`.
   Corroborating artifact: `data/tk11233-b1-peelstick.json` (307 KB of real offers) was
   produced by a v2.1 `productstatuses` call on **2026-09-04**, i.e. 17 days *after* the
   "sunset 2026-08-18" date asserted in commit `b59c029`.
2. **The wired surface was already migrated**, in three commits that predate this session:
   `b59c029` (check.mjs), `6b9c51f` (delete primitives + full sweep), `e7bb833` (orphan-reconcile).

## 2. Wired-surface exposure: ZERO

All three scheduled entrypoints are clean of Content API v2.1:

| launchd job | entry | Google API | status |
|---|---|---|---|
| `com.steve.google-merchant-agent` | `run.sh` → `check.mjs` | Merchant API v1 (`accounts/v1`, `issueresolution/v1`) | ✅ migrated |
| `com.steve.gmc-orphan-reconcile` | `reconcile.sh` → `gmc-orphan-reconcile.mjs` | Merchant API v1 (`products/v1`) | ✅ migrated |
| `com.steve.gmc-canary-recheck` | `gmc-canary-recheck.mjs` | **none** — Shopify Admin + CNCP only | ✅ N/A |

Empirical confirmation: the 4x/day job ran clean at `2026-09-10T02:08:18-07:00`,
`merchant_center.reachable=true`, 63,091 product statuses scanned. No Content API errors
anywhere in `data/run.log`.

## 3. Residual inventory (`call-site-inventory.json`)

| bucket | files | call sites |
|---|---|---|
| **Merchant API v1** (migrated) | 6 | 10 |
| **Content API v2.1** (residual) | 46 | 67 |
| — of which GET-only | 27 | — |
| — of which mutating (POST/DELETE) | 19 | — |
| Shopify-only / no remote API | 32 | — |

Migrated six: `check.mjs`, `gmc-orphan-reconcile.mjs`, `gmc-full-orphan-sweep.mjs`,
`gmc-full-orphan-delete.mjs`, `gmc-delete-for-product.mjs`, `tk10813-cohort-verify.mjs`.

All 46 residual files are **historical per-ticket one-offs** (`tk10702-*`, `tk10862-*`,
`tk11233-*`, `prep-*`, `repro-*`). None is scheduled. None is invoked by any `.sh` entrypoint.

**Architecture note that bounds the risk further:** every `gmc-*publish` / `unpublish`
script is **Shopify-only**. DW publishes to Google through the Shopify *Google & YouTube*
sales channel, so no publish path touches a Google API at all. Only *read* and *delete*
tooling talks to Google directly — and the delete tooling is the part already migrated.

**Non-issue confirmed:** the OAuth scope is identical across both APIs
(`https://www.googleapis.com/auth/content`, 52 refs). No credential or service-account work is required.

## 4. Known unknown — stated, not assumed

The probe establishes **READ** liveness on v2.1 only. Google sunsets are frequently
**staged**: writes cut first while reads linger. **Write liveness on v2.1 is UNVERIFIED**,
and cannot be verified without executing a real Merchant Center write — a gated,
customer-facing action. So the 19 mutating legacy scripts must be treated as
*possibly already broken*, not as proven-working.

This does not change the disposition, because all 19 **fail closed**: a sunset endpoint
returns 4xx and the script aborts having changed nothing. The failure mode is lost
operator time during an incident, not data damage.

## 5. Recommended disposition — do NOT bulk-migrate

Migrating 46 spent one-off scripts costs real effort and buys ~nothing: they are not
scheduled, they fail closed, and most will never run again. The leverage is elsewhere:

- **(A) Add a v2.1 shutoff canary** — a read-only probe of both API families emitting
  `PASS`/`WARN`/`FAIL` into `fleet-health-rollup`. This converts "silent latent risk" into
  "we find out the day it happens". *Gated: installs a launchd job.*
- **(B) Mark the 46 legacy scripts** — a header banner + a break-glass env var so an
  operator reaching for one mid-incident is told immediately that it is a sunset path and
  pointed at §6. *Reversible-local, but the surface is currently owned by another live
  session (TK-10813) — must be coordinated, not raced.*
- **(C) Ship the playbook below** so any revival is a 20-minute job, not a research project.

## 6. Just-in-time migration playbook — derived from our own working code

Every gotcha below is already **solved** in `gmc-delete-for-product.mjs`; this is that
working recipe generalized. Copy from that file rather than writing fresh.

| # | Content API v2.1 | Merchant API v1 | trap if you skip it |
|---|---|---|---|
| 1 | `content/v2.1/{merchant}/products/{id}` | `products/v1/accounts/{merchant}/products/{lang}~{feedLabel}~{offerId}` | ID separator changes `:`→`~` **and the `online:` channel segment is dropped**. A naive split/join silently builds a 404 name. |
| 2 | `DELETE .../products/{id}` | `DELETE .../productInputs/{name}?dataSource={ds}` | Deletes go to **productInputs**, not products, and **`dataSource` is mandatory**. Omit it → the delete fails. |
| 3 | — | resolve `ds` first: `GET .../products/{name}` → `.dataSource` | You cannot guess the owning data source; it must be read back per offer. `gmc-delete-for-product.mjs:lookupDataSource()`. |
| 4 | `?maxResults=250`, response `.resources[]`, `.nextPageToken` | `?pageSize=1000`, response `.products[]`, `.nextPageToken` | Reading `.resources` against Merchant API yields a silent empty loop that looks like "0 offers" — a false clean. |
| 5 | flat fields (`product.title`, `product.brand`) | nested under `.productAttributes` | Attributes read as `undefined`; filters match nothing. |
| 6 | `price: {value, currency}` | `price: {amountMicros, currencyCode}` | Off-by-10⁶ price math. Divide `amountMicros` by 1e6. |
| 7 | `productstatuses` | `issueresolution/v1/.../aggregateProductStatuses` | Status/issue surface moved to a different sub-API. See `check.mjs:123`. |
| 8 | account issues via `accounts`/`accountstatuses` | `accounts/v1/accounts/{merchant}/issues` | See `check.mjs:110`. |
| 9 | v2.1 error payloads | new codes/reasons | Retry classifiers keyed on old error shapes mis-handle 429/5xx. Re-check retry logic on migration. |

Token/auth is **unchanged** — reuse `mcToken()` verbatim (same JWT, same
`auth/content` scope, same `oauth2.googleapis.com/token` exchange).

## 7. Gate status

Nothing in this work fired a gated action. Deliverables are read-only analysis plus this
document. Items (A) and (B) are drafted to `~/.claude/yolo-queue/pending-approval/` and
are **not** executed here.