# Cro Page Registry Sync

> Daily sync of the Notion Page Registry: pulls last-7-day traffic from BigQuery, adds new pages, updates Daily Traffic and CVR, syncs cross-database fields (insights, hypotheses, hit rate), and computes each page's Research Status. Runs as the cro-page-registry-sync Oz cron at 06:00 UTC.

- Skill: `warpdotdev/cro-page-registry-sync` (Agent Skill)
- Install (CLI): `npx skillmds@latest add warpdotdev/cro-page-registry-sync`
- Raw SKILL.md: https://api.skillmd.com/api/skills/warpdotdev/cro-page-registry-sync/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- Author: warpdotdev (https://skillmd.com/u/warpdotdev)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/warpdotdev/cro-page-registry-sync

---


# Page Registry Daily Sync

## Configuration

Read `.warp/skills/cro/references/config.md` for data access setup (BigQuery auth, Notion token).

## Notion Databases

All databases are children of the CRO Experiment Agent page (`NOTION_CRO_AGENT_PAGE_ID`).

- **Page Registry**: `NOTION_PAGE_REGISTRY_DB_ID`
- **Knowledge Base** (insights): `NOTION_KNOWLEDGE_BASE_DB_ID`
- **Analysis Log**: `NOTION_ANALYSIS_LOG_DB_ID`
- **Roadmap & Backlog** (hypotheses): `NOTION_ROADMAP_DB_ID`

## Protected Fields — HARD RULE

**`CRO Excluded` and `Owner` must NEVER appear in any Notion API create or update payload from this skill.**

These fields are human-managed. To enforce this programmatically, use the following validation before every Notion API call that creates or updates a Page Registry entry:

```python
FORBIDDEN_FIELDS = {'CRO Excluded', 'Owner'}

def validate_payload(properties: dict) -> dict:
    """Strip forbidden fields from a Notion properties payload. Log if any were present."""
    violations = FORBIDDEN_FIELDS & set(properties.keys())
    if violations:
        print(f"WARNING: Stripped forbidden fields from payload: {violations}", file=sys.stderr)
    return {k: v for k, v in properties.items() if k not in FORBIDDEN_FIELDS}
```

Call `validate_payload()` on every `properties` dict before passing it to the Notion API. This applies to:
- Step 3 (creating new pages)
- Step 4 (updating traffic/CVR)
- Step 5 (updating cross-database fields)
- Step 6 (setting Research Status)

If a payload contains `CRO Excluded` or `Owner`, strip them and log the warning — do NOT abort the run. The sync should complete normally; just never write those fields.

## Execution Steps

### Step 1: Query BigQuery for page traffic (last 7 days)

Run this SQL via `bq query --nouse_legacy_sql --format=csv`:

```sql
select
    path
    , count(*) as total_pageviews_7d
    , round(count(*) / 7.0, 1) as avg_daily_pageviews
    , count(distinct anonymous_id) as unique_visitors_7d
    , round(safe_divide(countif(did_signup_within_30_days), count(*)) * 100, 2) as signup_cvr_pct
from `your-gcp-project.analytics.website_conversion`
where
    pageview_date >= date_sub(current_date(), interval 7 day)
    and not is_excluded_page_type_1  -- e.g. internal tool pages; replace with your own filter
    and not is_excluded_page_type_2  -- e.g. invite/referral pages; replace with your own filter
    and property in ('your-rudderstack-property')  -- replace with your Rudderstack property name(s)
    and not is_logged_in
group by path
having avg_daily_pageviews >= 10
order by avg_daily_pageviews desc
```

### Step 2: Fetch current Page Registry from Notion

Query the Page Registry database. For each existing page, note its `page_id` and current `Page Path`.

### Step 3: Add new pages

Before adding any page, build the **variant slug set** (see Variant Slug Detection below) so you can skip A/B test variant pages — they are never standalone pages in the registry.

For each path in the BigQuery results that is NOT already in the Page Registry:
1. Filter out junk paths (see Junk Filter Rules below)
2. Filter out variant slugs (see Variant Slug Detection below) — these are experiment variants, not real pages
3. Create a new Notion page with:
   - `Page Path`: the path
   - `Daily Traffic`: rounded avg_daily_pageviews
   - `CVR`: signup_cvr_pct / 100 (Notion stores as decimal)
   - `Research Status`: computed per Step 6

Leave `Owner` empty on new pages — it's a human-managed select.

Pages that drop below the traffic threshold stay in the registry (do not archive for low traffic alone). See Variant Slug Detection below for the one case where the agent _is_ allowed to archive entries.

### Step 4: Update traffic and CVR for all pages

For every page in the registry (existing + new), if it appears in the BigQuery results:
- Set `Daily Traffic` to the rounded avg_daily_pageviews
- Set `CVR` to signup_cvr_pct / 100

If a page is in the registry but NOT in the BigQuery results (dropped below threshold), set `Daily Traffic` to 0 but do NOT remove it.

### Step 5: Update cross-database fields

For each page in the registry, query the other Notion databases and compute:

**Insights** (from Knowledge Base `NOTION_KNOWLEDGE_BASE_DB_ID`):
- Count rows where the `Page` select equals this path.

**From Analysis Log** (`NOTION_ANALYSIS_LOG_DB_ID`):
- Find all entries where `Pages Analyzed` multi_select contains this path AND `Status` = "completed"
- `Last Researched`: the latest `Date` value
- `Latest Report`: the `Full Report` URL from the most recent completed entry

**From Roadmap & Backlog** (`NOTION_ROADMAP_DB_ID`):
- Find all entries where `Page` select equals this path
- `Untested Hypotheses`: count where Status starts with 0-, 1-, 2-, 3-, or 4-
- `Tested Hypotheses`: count where Status starts with 5-, 6-, 7-, 8-, or 9-
- `Hit Rate`: count(Status = "9-shipped") / count(Status in ["8-no-ship", "8-inconclusive", "9-shipped"]). Null if denominator is 0.

### Step 6: Compute and set Research Status

For each page, evaluate these conditions in order (first match wins):

1. `CRO Excluded` is TRUE → **cro-excluded**
2. `Daily Traffic` < 50 → **low-traffic** (page does not have enough volume to support experiments; no point flagging missing Primary Metric if traffic can't support an experiment)
3. `Primary Metric` is empty → **needs-primary-metric** (research and build can't run without one — surface this on the Page Registry so a human can fill it in)
4. `Last Researched` is null → **never-researched**
5. `Untested Hypotheses` ≤ 3 → **generate-more-hypotheses**
6. `Tested Hypotheses` ≥ 8 AND `Hit Rate` < 0.50 → **examine-hit-rate**
7. Otherwise → **current**

Pages set to `low-traffic` are automatically skipped for research and hypothesis generation. When traffic increases above 50, the next daily sync will advance them to a research-eligible status (e.g. `never-researched` or `generate-more-hypotheses`).

Set the `Research Status` select field to the computed value.

**Do NOT touch `CRO Excluded` or `Owner`.** Both are human-managed. The `validate_payload()` guard (see Protected Fields above) enforces this programmatically — even if you accidentally include them, they will be stripped before the API call.

The `needs-primary-metric` state is the agent's way of asking a human for a missing input. Humans scanning the Page Registry will see it as a badge; once they fill in `Primary Metric`, the next sync will auto-advance the page to `never-researched` / `generate-more-hypotheses` / etc. and the normal flow resumes.

### Step 7: Sync select options on related databases

Ensure all page paths from the registry exist as options in:
- Knowledge Base → `Page` select
- Roadmap & Backlog → `Page` select
- Analysis Log → `Pages Analyzed` multi_select

This allows new pages to be tagged in those databases immediately.

---

## Junk Filter Rules

Exclude paths matching any of these patterns (case-insensitive). Tune this list to
your own site's structure — the entries below are illustrative:
- Contains `---wip`
- Starts with `/archive/`
- Is exactly one of a small set of non-content routes (e.g. `/test-page`, `/page`, `/home`)
- Contains `%` followed by hex digits (URL-encoded garbage)
- Starts with `/download` followed by a non-slash character (e.g., `/download)`, `/download.`)
- Matches `/pricing-[a-c]` (A/B test variants)
- **Ends with `-v<digits>`** (e.g. `/example-page-v2`, `/example-page-v3`) — A/B test variant naming convention
- Is exactly a known typo/placeholder route
- Is exactly `/legal-notice` or other boilerplate legal pages
- Starts with a report/confirmation prefix that isn't a real landing page
- Contains obvious template artifacts (e.g. `[superscript`)
- Is exactly `/blog` (index rather than a landing page)

---

## Variant Slug Detection

A/B test variants live alongside control pages but must NEVER be added to the Page Registry — they are per-experiment artifacts that the build skill creates and the ship/kill PR removes.

There are **two sources** of experiments (see `your-marketing-site/docs/ab-testing-and-tracking.md`): code-defined in `src/lib/ab/experiments/*.experiment.ts` (collected by `experiments/index.ts`) and Sanity-defined `experiment` documents. Both can declare variants, and Sanity wins on ID collision at runtime, so the variant slug set must be the union of both.

**1. Code-defined variants.** Scan all `*.experiment.ts` files in `your-marketing-site/src/lib/ab/experiments/` and collect only non-control `variants[].url` values. The schema is:

```ts
{ id: string, path: string, variants: [{ id, url, weight }, ...] }
```

The field is `url` (not `pagePath` / `variantPath`). **Do not collect the experiment `path` itself, and do not collect any variant whose `id` is `control` or whose `url` equals the experiment `path`.** Control pages are real production pages and must remain in the Page Registry even while an experiment is running.

Use a small Node script that imports `experiments/index.ts` and iterates the array:

```bash
node - <<'NODE'
const { experiments } = require('./your-marketing-site/src/lib/ab/experiments')
const variants = new Set()
const controls = new Set()
for (const exp of experiments) {
  if (exp.path) controls.add(exp.path)
  for (const v of exp.variants || []) {
    if (!v.url) continue
    if (v.id === 'control') continue
    if (v.url === exp.path) continue
    variants.add(v.url)
  }
}
for (const c of controls) variants.delete(c)
console.log([...variants].sort().join('\n'))
NODE
```

If importing the TypeScript index is not possible in the runtime environment, parse the experiment files with a script that preserves each experiment's `path` and each variant's `id`; do not use a plain regex that extracts every `url:` because that will incorrectly include control URLs.

**2. Sanity-defined variants.** Query the Sanity API for active experiments:

```bash
curl -s -G "https://your-sanity-project.api.sanity.io/v2026-04-05/data/query/production" \
  --data-urlencode 'query=*[_type == "experiment" && status == "active"]{ path, variants[]{ id, url } }' \
  -H "Authorization: Bearer $SANITY_API_READ_TOKEN" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); controls={r.get('path') for r in d['result'] if r.get('path')}; variants={v['url'] for r in d['result'] for v in (r.get('variants') or []) if v.get('url') and v.get('id') != 'control' and v.get('url') != r.get('path')}; print('\n'.join(sorted(variants-controls)))"
```

Union (1) + (2) with any path in the BigQuery set that ends in `-v<digits>`, then subtract every known experiment control path. Every path left in this union is a variant and must be:

1. Skipped in new page creation (Step 3).
2. Skipped in the `Daily Traffic`/`CVR` updates (Step 4) — they belong to an experiment, not a page.
3. **Silently archived** if already in the registry:

   ```bash
   curl -s -X PATCH "https://api.notion.com/v1/pages/{{PAGE_ID}}" \
     -H "Authorization: Bearer $NOTION_INTEGRATION_TOKEN" \
     -H "Notion-Version: 2022-06-28" \
     -H "Content-Type: application/json" \
     -d '{"archived": true}'
   ```

Archiving is deterministic (the path matches the variant filter) and silent. Variant pages are ephemeral artifacts of an experiment and should never drive research, hypothesis generation, or registry bookkeeping; removing them is a routine cleanup.

---

## Field Reference (Page Registry)

| Field | Type | Source | Notes |
|---|---|---|---|
| Page Path | title | BigQuery | Never deleted, only added |
| Owner | select | **HUMAN ONLY** | Team member responsible for the page |
| CRO Excluded | checkbox | **HUMAN ONLY** | Never touch this field |
| Daily Traffic | number | BigQuery | Avg daily pageviews (7d) |
| CVR | number (%) | BigQuery | Signup CVR (7d) |
| Insights | number | Knowledge Base | Count of insight rows for this page |
| Last Researched | date | Analysis Log | Latest completed analysis date |
| Latest Report | url | Analysis Log | URL from most recent analysis |
| Untested Hypotheses | number | Roadmap & Backlog | Status 0-4 count |
| Tested Hypotheses | number | Roadmap & Backlog | Status 5-9 count |
| Hit Rate | number (%) | Roadmap & Backlog | 9-shipped / (8-no-ship + 8-inconclusive + 9-shipped) |
| Research Status | select | Computed | Options: `cro-excluded`, `needs-primary-metric`, `low-traffic`, `never-researched`, `generate-more-hypotheses`, `examine-hit-rate`, `current`. See Step 6 logic |

