# Plan quality: rules Q01 to Q13, verdicts and plan formats

## Contents

- How the rules report
- Rules Q01 to Q13
- Verdict labels and drift depth
- Normalizing a plan into two tabs
- Canonical copy and plan copies
- After conversion

## How the rules report

- Every rule prints coverage (passed of total) and its offenders, the first 8 then "and N more". `scripts/audit_plan.py quality` applies them mechanically.
- FAIL: a gap that blocks drift rules or leaves a tier 1 event without a contract field. WARN: a gap that weakens the audit. PASS: the rule ran and found nothing. NOT CHECKED: the column the rule needs is missing.
- Never blend the rules into one score. A plan can be fully typed (Q05 PASS) and have no triggers (Q01 FAIL); a blended percentage hides which drift rules can run.
- Retired events are left out of Q01 to Q04 and Q13, because their contracts are kept for history. Deprecated events are included: they still emit.

## Rules Q01 to Q13

### Q01 contract-core

- Check: trigger, must_not_fire with at least 2 items, grain, sources and risk tier are filled and are not template text (`<...>`, TBD, TODO).
- FAIL: missing on a tier 1 event. WARN: missing on other events; a success event whose trigger starts with clicked, submitted, opened, loaded or viewed and names no commit, persist, store, confirm or success condition (an intent trigger). Events ending in `_started`, `_clicked`, `_viewed`, `_opened`, `_submitted`, `_attempted`, `_requested`, `_failed`, `_shown` or `_loaded` are intent events by name and are not checked for this.
- Why: without a trigger and a must-not-fire list nobody can say whether an observed count is right. Intent triggers count clicks, not facts.
- Agent adds: read each intent-trigger WARN. "The user clicks Pay and the payment service confirms" is a commit trigger worded badly; fix the wording, not the event.

### Q02 consumer

- Check: consumer names at least one metric, report, experiment or workflow.
- FAIL: tier 1 without a consumer. WARN: other events without one.
- Why: an event with no consumer can be neither broken nor unused in any way that matters, and retirement depends on knowing there is none.
- Agent adds: the Mixpanel Lexicon CSV carries query counts (they include API queries), and each tool's saved reports, cohorts and key events are evidence of consumers the plan forgot.

### Q03 owners

- Check: business_owner, implementation_owner and data_owner are named.
- FAIL: no owner at all. WARN: any role missing.
- Why: every plan edit in the change list needs approvers by risk tier.

### Q04 identifiers

- Check: an occurrence ID rule (`event_id`), an actor ID (`user_id`, never an email) and, when any event names `account_id` or `workspace_id`, the account or workspace at event time on every event.
- FAIL: a tier 1 event without an occurrence ID rule; `user_id` defined as an email. WARN: no actor ID; no account at event time.
- Why: without `event_id`, D12 cannot separate a duplicate from a repeated action. Email IDs are personal data.

### Q05 typed-properties (blocking)

- Check: every property row has string, integer, number, boolean, timestamp, array or object. Common synonyms are accepted (int, float, bool, datetime, list, dict).
- FAIL: a required property without a valid type, no type column, or no property rows at all. WARN: an optional property without a type.
- Why: D10 compares observed value types with the plan type.

### Q06 value-rules

- Check: string properties state allowed values (separated by `|`) or a format; every property states `unknown_value_policy` and `null_means`.
- WARN: any gap.
- Why: D11 needs the allowed list and the policy (reject or send_unknown_and_alert) to set severity.

### Q07 one-definition (blocking)

- Check: each event and each event and property pair sits on one row; every property row names a planned event or `*`; one type per property name across events. Compare shared names with `data-dictionary.md` by hand.
- FAIL: a name defined twice ("event X is defined on lines a and b"); property rows that name events missing from the events tab, such as a typo; two types for one name. WARN: event names that differ only by case or separators.
- Why: a repeated row replaces the first in the script, and a row with a misspelled event counts in Q05 to Q12 but is never checked in drift. A shared property lands in one warehouse column and one Mixpanel or Amplitude property, so two planned types guarantee a mismatch somewhere. A repeated event or property also stops `drift` with exit code 2, because its results would depend on which row wins.

### Q08 destination-names (blocking)

- Check: every tool named in `delivery_paths` has a final name in its column (`ga4_name`, `mixpanel_name`, `amplitude_name`, `posthog_name`, `segment_name`, `rudderstack_name`), and no two active events share a final name in one tool column. "not sent", "none", "n/a", "-" and empty count as unmapped.
- FAIL: routed but unnamed; "<tool> name X is mapped from events A and B", because drift gives every row to the first event. WARN: a name is set but `delivery_paths` does not list the tool (Segment and RudderStack are skipped, since they are usually routes).
- NOT CHECKED: no `delivery_paths` column. Drift then maps each audited tool from its name column alone.
- Why: drift compares final names per tool, so a GA4 name that differs from the canonical name on purpose is never called a variant.

### Q09 destination-limits

- Check: GA4 final names of 40 characters or fewer that follow Google's naming rule (a letter first, then only letters, numbers and underscores; non-English letters count) and use no reserved name or prefix; no planned GA4 parameter with a reserved prefix; no more GA4 parameters per event than the property's limit, counting global rows not excluded for GA4. The limit is 25 on standard properties and 100 on Analytics 360: pass `--ga4-param-limit 100` for 360 and record the tier in the baseline. Reserved names and prefixes load from the `@ga4-reserved` sections of [../assets/vendor-events.txt](../assets/vendor-events.txt) and [../assets/vendor-params.txt](../assets/vendor-params.txt), so refresh your copies from the live pages; a list without the section prints a note and that part is skipped.
- FAIL: any of these.
- Why: GA4 does not log an event name over 40 characters, cannot mark it as a key event, and drops parameters past the limit. Google's event naming rules say reserved names and prefixes can't be used, and the Firebase SDK drops reserved event names. Parameters the tag adds are not in the plan; count them from one DebugView capture.
- Agent adds: a reserved name is allowed only for Google's own event with its documented meaning, recorded as an exception in `decisions.md`. House naming rules stay with the event-naming-convention linter, and destination collisions with tracking-plan-builder's `check_plan.py`; report their summary, or mark naming NOT CHECKED.

### Q10 lifecycle

- Check: status is proposed, approved, implemented, verified, deprecated or retired; version is an integer; deprecated and retired rows are kept with `status_changed_at`.
- FAIL: unknown status. WARN: missing or non-integer version; missing dates.
- Why: D01 and D05 read the status. Approved events are not expected yet; retired events must not arrive.

### Q11 privacy

- Check: no property is named like personal data (email, phone, first_name, last_name, full_name, address, ip_address, password, date_of_birth and similar, including names ending in `_email` or `_phone`); identity-carrying events list `pii_exclusions` and a `consent_category` where those columns exist.
- FAIL: a personal-data property name. WARN: missing exclusions or consent category.
- Why: a planned email property is a decision to collect personal data. It needs the privacy owner before release, not a fix after.

### Q12 money-and-units

- Check: money properties (value, total, revenue, price, amount, tax, shipping, discount, cost) and quantities (duration, size, quantity, ms, seconds) state a unit; money pairs with an ISO 4217 currency property on the same event and a business definition.
- WARN: any gap.
- Why: revenue totals that differ from the ledger by a factor of 100 usually mean cents and whole units were mixed.

### Q13 acceptance

- Check: acceptance tests are named; negative cases T2.1 to T2.n match the must_not_fire items one to one. The script compares counts; the agent judges whether each case tests its item.
- FAIL: tier 1 without tests. WARN: other events without tests; a count mismatch.
- Why: a verified status is only as good as its tests. When the audit fails a verified event, its status goes back to implemented until the tests pass again.

## Verdict labels and drift depth

- contract: Q05, Q07 and Q08 pass for all events, Q01 to Q04 have no FAIL, and every active event has a risk tier. All drift rules run.
- schema only: Q05 and Q08 pass, but triggers, sources, consumers, owners or identifiers are missing for tier 1 events, Q07 fails, or tiers are missing. Vendor schema exports land here. Type, enum and completeness rules run; source and ratio rules need the missing fields from the owners. Drift treats an event with no tier as tier 2 and says so.
- names only: Q05 or Q08 fails. Type, enum and completeness rules run only for rows that carry a type, a required flag or allowed values; everything else prints NOT CHECKED. Say so in the report.
- A vendor schema export is a schema, not a contract. It can pass names and types, never triggers, sources of truth or consumers.

## Normalizing a plan into two tabs

Work in a scratch folder and never edit the source. Columns: [../assets/plan-events-template.csv](../assets/plan-events-template.csv) and [../assets/plan-properties-template.csv](../assets/plan-properties-template.csv), both header-only: copy the header rows into the scratch folder as `plan-events.csv` and `plan-properties.csv` and fill those, never the assets. The example-acme files show filled rows.

Separators inside plan cells: `; ` between items in `must_not_fire`, `sources`, `identifiers`, `acceptance_tests` and `delivery_paths`, and `|` between allowed values. A comma-separated `must_not_fire` reads as one item and fails Q01.

tracking-plan-builder's sheet tabs can be passed as they are. Its `source_system` column maps backend, billing_webhook and crm to server, browser to web, and mobile_app to ios and android. A delivery path marked browser companion, via gtm or via gtag (for example `ga4 via gtm (browser companion)`) is read as a web source for that tool when `sources` has no override for it, and the drift run prints which events it read that way. The properties tab's `scope` column (event, user or item) is what D15 compares with GA4 registrations.

- YAML contracts from tracking-plan-builder: one events row per contract, one properties row per property. A `ref: data-dictionary.md#...` property that every event carries becomes a global row with event `*`. `destinations.<tool>.name` fills `<tool>_name`. A browser companion becomes a source override such as `server; ga4:web`. Acceptance IDs go into `acceptance_tests` as `T1; T2.1-T2.6; T3-T7`.
- Segment Protocols CSV (Download Tracking Plan): events, properties, types, required flags and enum values. No triggers, owners, consumers or sources, so the verdict is schema only at best. Copy enum lists into `allowed_values_or_format` separated by `|`.
- Avo export (CSV or JSON): types; presence "Always sent" becomes required true, "Sometimes sent" required false, and "Never sent" a note that the property is not allowed; per-source presence fills `sources`; stakeholders fill owners where present.
- Amplitude Data (read with `get_amp_taxonomy` or the UI): planned, live and unexpected status, types, required flags and allowed values; schema only. Unexpected events are observed-side evidence, so keep them out of the plan tabs.
- Mixpanel Lexicon CSV: names, descriptions, tags, hidden or blocked status and query counts. Mixpanel's docs do not list the CSV columns, so map by header and never guess. Without types or required flags the verdict is names only.
- Spreadsheets: map by header. When a column's meaning is unclear, ask the plan owner.

## Canonical copy and plan copies

- Name one canonical plan; `decisions.md` should record where it lives. Audit the other copies as observed copies without counts.
- A planned event or value missing from the Protocols, Amplitude Data, Avo or RudderStack copy explains data that was blocked or flagged: copy drift, fixed in the copy, not in the code.
- If two copies disagree and neither is named canonical, stop and ask the plan owner which one wins.

## After conversion

- Row counts: events rows equal the number of contracts or plan events, and property rows equal the property definitions. Report any mismatch.
- List the contract fields that had no column (consent behaviour, late-arrival policy, dedupe mapping) as unmapped in the report baseline. They were not audited.
