| name | cro-page-registry-sync |
| description | 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. |
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:
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:
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
and not is_excluded_page_type_2
and property in ('your-rudderstack-property')
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:
- Filter out junk paths (see Junk Filter Rules below)
- Filter out variant slugs (see Variant Slug Detection below) — these are experiment variants, not real pages
- 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):
CRO Excluded is TRUE → cro-excluded
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)
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)
Last Researched is null → never-researched
Untested Hypotheses ≤ 3 → generate-more-hypotheses
Tested Hypotheses ≥ 8 AND Hit Rate < 0.50 → examine-hit-rate
- 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:
{ 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:
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:
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:
-
Skipped in new page creation (Step 3).
-
Skipped in the Daily Traffic/CVR updates (Step 4) — they belong to an experiment, not a page.
-
Silently archived if already in the registry:
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 |