# Governance

Consistency comes from three habits: generate names from the registry before build, lint twice, and review exceptions weekly by spend. Everything else supports those.

## Roles and approvals

Name a person for each role in `martech-context/naming-rules.md`. One person may hold several.

- Naming owner (usually marketing ops): owns the rules file, the vocabulary file and the registry, approves new vocabulary values, runs the weekly report.
- Data owner (analytics or analytics engineering): owns the parse model and its tests; co-approves anything that changes a reporting column.
- Campaign owners (in-house or agency): register objects before build, run the lint, fix their exceptions.

Who approves what. Every change below is proposed as a diff and written to `martech-context/` only after approval:

- New `offer` value: the campaign owner proposes it with the names and expected spend that need it; the naming owner approves before build and adds an `approved` row to `naming-vocab.csv`. Offers change monthly, so this approval should take less than a business day.
- New value for `market`, `objective`, `funnel`, `audience`, `targeting`, `placement` or `format`: naming owner and data owner, with a dated entry in `decisions.md`, because dashboards group by these.
- Replacing a value: never in place. Add the new value as `approved`; set the old row to `retired` with `alias_of` and `retired_on`. Same two approvers.
- Unrecognised values found by the lint or the weekly report are candidates, not additions. Nobody adds a value to make a name pass.
- New token, token order, delimiter or case rule: naming owner, data owner and the owners of affected reports. Bump `version` in the rules file, set a cutover date, and record `rules_version` on each registry row so the parser can branch.
- Exemption: naming owner. The registry row gets `naming_exempt` set to `yes` with a reason and review date in `notes`, and `platform:object_id` goes into `exempt_ids` in the rules file.
- Rename of a live object: the campaign owner requests it on the change list; the naming owner approves; a human applies it in the platform. Any agent-applied change goes through the agent-write-safety skill first.

## Registry

Template: `assets/campaign-registry-template.csv`. Rules for its use:

- One row per initiative-level object and one per responsive search ad (no name field). Add targeting-level and ad-level rows when reports need their attributes.
- The row exists before the object is built. `object_id` is filled within one business day of launch; a row without an ID is an open item in the weekly report.
- Generate `name` from the token columns; never type it. In a sheet with the template's column order, one formula covers every level because each row fills only its own level's token columns: `=LOWER(TEXTJOIN("_",TRUE,I2:T2))` (market in column I through variant in column T). Builders copy the generated name into the platform. Responsive search ads keep `name` empty.
- `utm_campaign` on initiative-level rows holds the value the links send: the UTM registry's initiative name for the same `initiative_id`, or the platform name on a GA4 connector route (`utm-mapping.md`). Once `object_id` is filled, these rows feed utm-convention-and-registry's platform campaign map, which its cost joins use.
- IDs are text, digits only: `1234567890` for a Google Ads customer the UI shows as `123-456-7890`, a Meta account ID without `act_`, a LinkedIn ID without its URN prefix. Owned-channel rows use the UTM registry's `cmp_` ID. A spreadsheet that converts long IDs to numbers corrupts them; format the ID columns as text before pasting.
- `predecessor_id`: the object this one replaces when spend moves to a new ID (a duplicate relaunch, an experiment converted to a new campaign). Reports can then join a continuing initiative across IDs.
- Attribute changes close the old row with `effective_to` and add a new row with `effective_from` and `change_type`: `correction` (a token was wrong; reports restate history "as is") or `attribute_change` (what runs changed and relaunching was not possible; reports show history "as was"). Never overwrite history.

Registry lint, run after every registry edit:

```bash
python3 scripts/lint_names.py --rules martech-context/naming-rules.yaml --registry martech-context/campaign-registry.csv
```

- It ignores closed rows and skips rows whose level has no rules (owned channels). `registry_mismatch` fails a row whose name differs from its token columns and suggests the generated name; `registry_tokens` fails a row with no token columns.
- Testing it: on the unchanged template the summary reads `8 current registry rows: 6 pass, 0 warn, 0 fail, 1 exempt, 1 skipped (no rules for the level)` and the exit code is 0.

## Pre-launch lint

1. Lint the planned names from the launch sheet. Any `fail` stops the build. A planned name has no `start_date` yet, so a retired value fails.
2. After build, export names from the platform (read-only) and lint again with `object_id`, `parent_id`, `status` and `start_date` filled. This catches retyped names, duplicated objects that kept an old launch month, duplicate names under one parent and IDs damaged on the way.
3. Save the lint report next to the launch record. A `warn` needs an owner note in the registry before launch.

Testing the linter itself: run it on `assets/example-names.csv` with `assets/naming-rules.yaml` (which reads `assets/naming-vocab.csv`). Each row's `expect` column lists the codes it should produce. With the templates unchanged, stderr reads:

```text
27 names: 11 pass, 4 warn, 11 fail, 1 exempt
spend on failing names: google_ads 23.9% of 2260.00, linkedin 0.0% of 700.00, meta 18.2% of 1100.00, tiktok 100.0% of 50.00
```

and the exit code is 1.

## Weekly exception report

Run it from the warehouse every week over the last seven complete days, ranked by spend, and send it to the naming owner and campaign owners.

Sections:

1. Spend on objects whose `parse_status` is not `ok` and that are not exempt.
2. Spend on object IDs with no registry row. Includes boosted posts, experiments converted to new campaigns, objects created by third-party buying tools and platform default names.
3. Drift: live name differs from the registry name.
4. Registry attributes disagree with the parsed live name (query below).
5. Renames in the last 7 days from the name history, and whether each has a change-list row. Without snapshot history, use the change sources in `platform-reference.md`: Google Ads `change_event` (past 30 days only), Meta's ad account activities (one week by default), LinkedIn `changeAuditStamps` compared with the previous export.
6. New vocabulary values seen in names: candidates to approve or fix.
7. GA4 campaign values with literal macro text, `utm_id` values that match no platform ID, and `utm_campaign` values that differ from the registry for their `utm_id`.
8. Launch month not matching the first spend month, and new IDs whose first spend date equals the last spend date of a paused object in the same initiative without `predecessor_id`.
9. Registry rows still missing `object_id`, and registry IDs that match no directory row.

Escalate to the naming owner when the spend in sections 1 and 2 is above 5% of a platform's weekly spend in one currency. Every listed item has an owner and is fixed or exempted before the next report.

A starting query for sections 1 and 2, in BigQuery. The share is computed per platform and currency, because accounts in different currencies cannot be summed. If the team keeps a converted `cost_reporting_currency` column with a named exchange-rate policy, partition by platform alone on that column instead, and say so in `metrics.md`.

```sql
WITH weekly AS (
  SELECT
    s.platform, s.account_id, s.campaign_id, s.currency,
    d.campaign_name, d.parse_status, d.attribute_source, d.in_registry, d.naming_exempt,
    SUM(s.cost) AS cost_7d
  FROM fct_campaign_spend_daily AS s
  LEFT JOIN dim_paid_campaign AS d
    ON d.platform = s.platform
    AND d.account_id = s.account_id
    AND d.campaign_id = s.campaign_id
  WHERE s.spend_date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
                         AND DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
  GROUP BY 1, 2, 3, 4, 5, 6, 7, 8, 9
),
with_share AS (
  SELECT
    *,
    SAFE_DIVIDE(cost_7d, SUM(cost_7d) OVER (PARTITION BY platform, currency)) AS share_of_platform_currency
  FROM weekly
)
SELECT *
FROM with_share
WHERE COALESCE(naming_exempt, '') != 'yes'
  AND (in_registry IS NOT TRUE OR parse_status IS NULL OR parse_status != 'ok')
ORDER BY cost_7d DESC
```

The share is computed over all of a platform's spend in that currency before the filter, so summing `share_of_platform_currency` per platform and currency in the result gives the number to compare with the 5% threshold. A NULL `parse_status` means the ID is missing from the directory.

Section 4, registry attributes against the parsed live name:

```sql
SELECT
  p.platform, p.account_id, p.campaign_id, p.campaign_name,
  r.name AS registry_name,
  ARRAY_TO_STRING(ARRAY(
    SELECT token FROM UNNEST([
      IF(p.market != r.market, 'market', NULL),
      IF(p.objective != r.objective, 'objective', NULL),
      IF(p.funnel != r.funnel, 'funnel', NULL),
      IF(p.audience != r.audience, 'audience', NULL),
      IF(p.offer != r.offer, 'offer', NULL),
      IF(p.launch_month != CAST(r.launch AS STRING), 'launch', NULL)
    ]) AS token WHERE token IS NOT NULL
  ), ', ') AS tokens_that_differ
FROM int_campaign_parsed AS p
JOIN campaign_registry AS r
  ON r.platform = p.platform
  AND r.account_id = p.account_id
  AND r.object_id = p.campaign_id
  AND r.level IN ('campaign', 'campaign_group')
  AND r.effective_to IS NULL
WHERE p.parse_status = 'ok'
  AND COALESCE(r.naming_exempt, '') != 'yes'
  AND (p.market != r.market OR p.objective != r.objective OR p.funnel != r.funnel
    OR p.audience != r.audience OR p.offer != r.offer
    OR p.launch_month != CAST(r.launch AS STRING))
```

Each row is either a typo in the platform (rename through the procedure below) or a wrong registry row (correct it with `change_type` `correction`). They should converge, not coexist.

## Rename procedure

1. The campaign owner adds a row to `assets/rename-change-list-template.csv`: old name, new name, reason, whether the meaning changed, `change_type`, effective date.
2. If the meaning changed (market, objective, funnel or audience of what is actually running), stop. Launch a new object with `predecessor_id`, or, when relaunching is not possible, record `change_type` `attribute_change` and keep the old name's history.
3. List what selects this object by name: automated rules, scripts, saved report and dashboard filters, bid or budget tools, connector filters. Update them in the same change.
4. A human applies the rename in the platform UI, Google Ads Editor or an approved API call. In Editor, leave Make changes in duplicate items unchecked.
5. The same day, close the registry row and add the new one, with `change_type`. Reports restate only after this step, because the registry wins over the name.
6. Next day: the export shows the new name with the same object ID, the name history shows a new period, the registry lint passes, and `registry_updated_at` and `verified_at` are filled on the change list.

## Cleaning up an existing account

Do not start by renaming everything. Renames do not fix reports that join on names, and they break rules and filters that select by name.

1. Week 1: build the ID-keyed directory, name history and parse model. Let legacy names show as `unknown`. Name history only starts when snapshots start, so record that date.
2. Backfill registry rows for every object with spend in the last 90 days, mapping legacy names to tokens by hand. The registry wins over the name, so reports become correct before any rename.
3. Set a cutover date. Every object launched after it must pass the lint.
4. Rename only live objects whose names people read in reports or platform views, highest spend first, through the rename procedure.
5. Exempt the long tail with a reason and a review date. Paused and removed objects stay as they are.
6. Track weekly, per platform: the registry match rate (share of spend on IDs found in the registry) and the parse rate (share with `parse_status` `ok`) separately, and the share with `attribute_source` of `registry` or `name`. The target for the last is at least 95%.
