# Drift rules D01 to D15: thresholds, reasons and confirmation

Severity means the same in every rule:

- FAIL: the data is wrong, duplicated, missing for a planned use, or carries personal data, unless a documented exception exists. Downgrading a FAIL requires citing `decisions.md` or the contract, and the report records the citation.
- WARN: a plausible defect or an open decision that needs an owner, more evidence or a runtime check.
- PASS: the rule ran and found nothing, stated with what was checked.
- NOT CHECKED: the rule could not run (missing column, no counts, no last_seen dates, no pattern counts, no source labels, no reference file, an unknown route, no values, no time zones, no custom definitions file), always printed with the reason. A PASS on the same rule names only the part that ran.
- INFO: coverage lines that are not findings (events not shipped yet, names listed without counts, transport duplicates, ratios inside their band, ratios with no band, the GA4 stream platform note).

`scripts/audit_plan.py drift` applies the rules mechanically. In `findings.csv`, `affected` counts what has the defect and `unit` says what it counts (rows without the property, rows out of list, event names, identifiers); share rules add `denominator` and `share_pct`, and D01, D02, D03 and D05 add `last_seen`. The "Agent adds" lines need the plan, `decisions.md` and the owners.

## Thresholds and why

- Window: 28 days ending 4 days before the audit. Four full weeks cover weekly cycles and most monthly flows, and the gap lets late data land: Google can change a GA4 daily table for up to 3 days after the table date (answers 7029846 and 9358801), so the 2026-10-30 table can still change on 2026-11-02 and is final on 2026-11-03. Amplitude export data is available up to 2 hours after upload, and mobile SDKs send queued events late. `--as-of` defaults to the earlier of today and 4 days after `--window-end`, and the run prints the date it used; a run closer than that labels the last days provisional, pads the stop rule and caps a server-route D12 shortfall at WARN. Vendor views are shorter (Avo issues 24 hours, Amplitude validation errors 24 hours, Amplitude Observe 14 days, Segment violations 7 days).
- Lookback: 90 days, for last-seen dates and for plan age before proposing removal of a never-seen event. Amplitude's planned-unseen guide uses 90 days as its example review period.
- Minimum sample: 100 occurrences (or 100 events for event-level shares). Below 100 one bad record moves a share by a point or more, so share-based FAILs cap at WARN with "low sample". Not capped: a property that is never present, and the any-mismatch rules for tier 1 IDs, money and server events.
- Stopped inside the window: rows in the window, but the last one is older than max(2, ceil(7 / daily rate)) days before the window end, where the daily rate is the rows divided by the window days. That is about seven expected rows of silence: at 183 rows a day a 3-day gap is not chance, at 1 row a day the bar is 7 days, and below about 0.25 rows a day a 28-day window cannot tell. Applied to the whole event and to each planned source.
- Type mismatch: FAIL at 1% or more of a property's occurrences, WARN above 0 and below 1%, FAIL at any share on an identifier or money property of a tier 1 event. Under 1% is usually one path, one old version or a test record; at 1% typed destinations drop or coerce enough values to bias sums and averages.
- Required completeness: FAIL below 95%, FAIL below 100% on a tier 1 server event, WARN from 95% to below 100%. Sentinel and empty strings count as missing. Below 95% a whole platform or path usually never sends the property; a server route has no consent or blocker loss, so any gap is a defect.
- Optional never seen: WARN when the event has 1,000 or more rows. At 1,000 events, a property sent on 0.5% of events or more has under a 1% chance of never appearing (0.995^1000 is about 0.7%).
- Enum drift: FAIL above 1% of rows out of the allowed list, or any out-of-list value under policy reject. "unknown" is allowed and counted under send_unknown_and_alert. The untallied tail (`<other>` beyond the top 50, `<rare>` for values seen fewer than 5 times) counts as out of list when every allowed value is already listed, because then it cannot hold one; otherwise the share is reported as a range.
- Global property: an unplanned property on 80% or more of a tool's event names is one finding, not one per event. Super properties and tag-added parameters would otherwise bury real defects.
- Dynamic names: 10 or more names or keys that collapse to one pattern after masking digit runs, UUIDs and hex IDs.
- Priority orphan: in the tool's top 20 by volume and at least 1% of its rows, or used by any consumer. A tool with 20 names or fewer would otherwise mark every orphan priority.
- Ratio to the system of record: FAIL above 1.0; FAIL below 0.99 on a server route once the refresh window has passed; WARN outside the band a contract documents for a browser route; no band means report the ratio and ask for one. When the two sides are not confirmed to cut days in the same time zone, a ratio from 0.99 to 1.01 is WARN. Browser loss varies, so no default band is invented.
- Duplicate occurrences: any repeated occurrence key (event_id, transaction_id, order_id) on a tier 1 event is a FAIL, WARN elsewhere; transport duplicates are INFO.
- Version tail: 90% or more of a defect's rows from app versions older than the fixing release is a WARN with an expiry recorded in `decisions.md`.
- GA4 ceilings: the parameter limit is 25 per event on standard properties and 100 on Analytics 360 (`--ga4-param-limit`); record the property tier in the baseline. FAIL when the plan maps more parameters than the limit to one event or a planned name exceeds 40 characters; WARN when a row carries as many custom parameters as the limit.
- Identifiers: FAIL on any email-like ID (D13), on placeholders at 1% or more, on numeric IDs above 2^53, and on short IDs upstream of Amplitude. An identifier the plan names with no null rule: FAIL at 1% or more null or placeholder on a tier 1 event, or any share on a tier 1 server event; WARN otherwise.
- After a fix: re-run the inventory for the event over 7 days or 1,000 events, whichever is later. Before any block or delete: zero rows in the tool for 7 consecutive days.

## D01 planned-unseen

- Condition: a planned event mapped to the tool (its name column is not "not sent") with zero rows in the window, or with rows that stop before the window ends.
- FAIL: stopped before the window (rows before it, none in it), always; stopped inside the window, for the whole event or one planned source, on a tier 1 or verified event; never seen when the status is verified or the event is tier 1. WARN: stopped inside the window on other events; never seen, status implemented, tier 2 or 3. INFO: proposed or approved, not shipped; listed without counts.
- Why: a verified status the tool contradicts is a broken release or a broken contract. An event that stopped on 12 October still has rows in a 28-day window, so a zero-rows test alone misses every release less than 28 days old.
- Stopped inside the window: the threshold above. A source that stops while others keep sending (iOS after an app release) is reported for that source. Sources the plan does not list are skipped, because a second producer that stops may be the fix; D06 reports them.
- Names-only listings: an event row whose `rows` cell is empty (a Lexicon CSV, `get_amp_taxonomy`, Segment Source Schema) is a name without counts. D01 prints INFO "listed without counts" and D05 and D06 do not run; `0` in the cell means zero rows.
- No dates: an event with rows but no `last_seen` cannot show a stop inside the window. The tool gets one NOT CHECKED line listing those events, and the D01 PASS drops "kept sending to its end". The GA4 Data API needs the date dimension for this.
- Guards: a planned GA4 name over 40 characters, one that breaks Google's naming rule, or a reserved name or prefix (the `@ga4-reserved` section of vendor-events.txt) is printed as the root cause. A missing tool column means canonical names were compared (NOT CHECKED note). An event seen only as a warehouse table or column list prints INFO, since there are no counts.
- Agent adds: search every emitter root before writing "not emitted" ([emitter-search.md](emitter-search.md)). Check GA4 modification rules for a rename. A never-seen event older than 90 days in the plan with no roadmap goes to its owner as a status decision.
- Default disposition: stopped, fix implementation, and a verified status goes back to implemented until its acceptance tests pass again. Never seen, needs decision (fix the emitter, or move the status back to approved).
- Confirm: daily counts around `last_seen` (GA4: `SELECT event_date, COUNT(*) AS row_count ... WHERE event_name = 'sign_up' GROUP BY event_date ORDER BY event_date`; raw exports: the profiler with `--since` and `--until` set to one day at a time), then the emitter location and a release date near `last_seen`; tracking-break-map when nobody knows which layer stopped.

## D02 unplanned-event

- Condition: an observed name not in the plan for that tool, not a vendor event, not a variant and not a dynamic or per-page name.
- WARN: every orphan, labelled priority when it is in the top 20 by volume with at least 1% of the tool's rows, test when a name token is test, tmp, temp, debug, qa or staging, and "listed without counts" when a names-only route shows it. INFO: seen only before the window.
- Why: high-volume orphans drive event-based bills and are what analysts find first. Any consumer means someone already relies on an undefined event.
- Guards: [../assets/vendor-events.txt](../assets/vendor-events.txt) with tool-scoped prefixes (`$` for Mixpanel, PostHog and Amplitude system events, `[Amplitude]`), GA4 automatically collected events, the mobile lifecycle events of the Segment, RudderStack and PostHog SDKs (Application Opened and the rest, only when the SDK option is on), the consolidated "Loaded a Page" and "Loaded a Screen" events that Segment's Amplitude and Mixpanel destinations create, Segment's "Violation Generated" and "Segment Consent Preference Updated". A planned event's canonical name seen in a tool where the plan maps another name is reported with that note. GA4 created events can produce names no code sends.
- Agent adds: consumers (saved reports, cohorts, Lexicon query counts), the emitter, and whether the event should become a planned intent event.
- Default disposition: needs decision (adopt into the plan through tracking-plan-builder, or retire). Test names: retire after confirming no real users send them.

## D03 name-variant

- Condition: an observed name that normalizes (lowercase, camelCase split, spaces, hyphens, dots, colons and slashes to underscores) to a planned name of that tool, or to another unplanned observed name, but differs.
- FAIL: the variant has rows in the window (for two unplanned spellings, both do), with the share of the action's rows under the unplanned spelling. WARN: the variant is stale.
- Why: one action under two spellings splits funnels and doubles maintenance. Segment warehouses put both spellings in one table because they share a slug, while the analytics tools keep them apart.
- Guards: final names are compared per tool, so a GA4 name that differs by design is not a variant. A warehouse table that matches a planned name by slug is that event.
- Agent adds: which emitter sends each spelling (a platform team, or a connector's lowercase and underscore settings). The canonical spelling is the plan's name when consumers use it; never choose by volume or naming style.
- Default disposition: fix implementation (stop or rename the non-canonical emission). Tool-side merges and display names leave raw data split; renames follow event-naming-convention.

## D04 dynamic-names

- Condition: 10 or more observed event names, or unplanned property keys, that collapse to one pattern after replacing UUIDs, hex strings of 8 or more characters and digit runs of 2 or more. Also unplanned names of the form "Viewed <name> Page", "Loaded <name> Page" or the same with Screen, which a CDP destination's Track Named Pages or Track Categorized Pages setting creates, one event type per page.
- FAIL: a masked pattern, always, one finding per pattern with the count and masked examples; per-page names when there are 10 or more. WARN: fewer per-page names.
- Why: IDs and page names in event names explode cardinality and use up type limits (Amplitude indexes 2,000 event types; a Mixpanel Lexicon export file holds 5,000 events).
- Guards: Segment's iOS device mode with Track All Pages sends "Viewed <name>" with no suffix, which the pattern does not catch; check those by hand.
- Default disposition: fix implementation (move the variable part into a property). Per-page names: set the destination to Track All Pages, which sends one "Loaded a Page" event with the page name as a property, after moving the reports that read the old names.

## D05 lifecycle

- Condition: rows for an event whose status is deprecated or retired; rows for an event still marked proposed or approved.
- FAIL: retired. WARN: deprecated, raised to FAIL when a metric counts both old and new names during a rename overlap; proposed or approved with rows, because the plan lags the release.
- Why: a retired event in the data means a forgotten emitter or installs that never updated.
- Agent adds: split the rows by app version in a separate profiler run (`profile_export.py --source-key version_name > versions-amplitude.csv`) and read that file by hand; never pass it to drift, which refuses version-like sources. When 90% or more of the rows come from versions older than the fixing release, rate WARN with an expiry recorded in `decisions.md`, and cite it.
- Default disposition: fix implementation, or a status correction (retired back to deprecated with an end date) recorded as a decision. Status lag: amend plan.

## D06 source-mismatch

- Condition: rows from a source the plan does not list for that tool, or no rows from a listed source while other sources send.
- FAIL: tier 1 events. WARN: other events.
- Why: an unexpected source is usually a second producer; a missing source is a platform that never shipped the event.
- Guards: `tool:source` overrides such as `ga4:web` for a browser companion. A delivery path marked browser companion, via gtm or via gtag counts as a web source for that tool when `sources` has no override for it, so tracking-plan-builder sheets work as they are; the run prints which events it read that way. tracking-plan-builder `source_system` values are mapped.
- Unlabelled rows: rows with an empty or unknown source are never compared. When an event has any, missing sources are not reported, and the tool gets one NOT CHECKED line listing the events. The GA4 Data API needs the platform dimension next to eventName for this rule.
- GA4 server routes: GA4 records the data stream platform (web, ios or android) on every row, so Measurement Protocol and server-side tagging rows arrive under a stream platform. For a GA4 event planned from a server, stream platforms are accepted and an INFO line says that D06 cannot see a second producer there; D12 against the system of record can.
- Default disposition: fix implementation. Confirm: the producer behind the unexpected source (library name, user agent, server log).

## D07 required-incomplete

- Condition: a required property present (non-null, not a sentinel, not empty) on less than 100% of the event's rows, per tool and source.
- FAIL: below 95%, or below 100% on a tier 1 server event. WARN: 95% to below 100%. Low sample caps a share FAIL at WARN unless the property is never present. `affected` in `findings.csv` is the rows without the property.
- Why: below 95% a path or platform does not send it; a server route loses nothing to consent or blockers.
- Guards: `excluded_destinations` skips tools that must not receive the property; GA4 names come from `ga4_parameter`; sources labelled differently on event and property rows are compared in aggregate; an event with no property rows at all gets a note to confirm the inventory included it.
- Agent adds: split by source and app version. When a second producer explains the gap (the share present equals the planned producer's share), group it with D12 in one change request. D07 measures fields on rows that arrived and says nothing about rows that never did: 100% completeness beside a 0.90 ratio to the system of record still loses one fact in ten. Compare the same property in the warehouse (Segment Query 2b) and in each tool to find enforcement that drops it silently.
- Default disposition: fix implementation. Confirm: a payload from the failing path and the code path that omits the property.

## D08 optional-unseen

- Condition: a planned optional property with zero occurrences on an event with 1,000 or more rows in that tool.
- WARN, always. A global optional property prints one line listing the events.
- Default disposition: needs decision (implement it or drop it from the plan).

## D09 unplanned-property

- Condition: an observed property on a planned event that the plan lists neither for the event nor globally, and that is not a vendor parameter.
- WARN: per event, or one "unplanned global property" finding when it is on 80% or more of the tool's event names.
- Guards: [../assets/vendor-params.txt](../assets/vendor-params.txt) (GA4 `ga_`, `firebase_`, `google_`, `gtag.`, `_` prefixes and page fields; `$` for Mixpanel and PostHog; the UTM parameters and click IDs that the Mixpanel and PostHog web SDKs add; `[Amplitude]`); Segment array children ending in `.$`; personal-data names go to D13 instead.
- Default disposition: needs decision (adopt into the plan or stop sending).

## D10 type-mismatch

- Condition: an observed value type incompatible with the plan type, or two type families on a property the plan does not type.
- Compatibility: string accepts string, numeric_string and timestamp; integer accepts integer; number accepts integer and number; boolean accepts boolean; timestamp accepts timestamp, and integer only when the unit says epoch; array and object accept themselves. Null counts toward completeness, never as a mismatch.
- FAIL: 1% or more of occurrences; any share on an identifier or money property of a tier 1 event; a column-level mismatch on an identifier or money property. WARN: below 1%; column-level mismatches on other properties; low sample; inconsistent types on an untyped property.
- Messages: numbers sent as text, booleans sent as text, IDs sent as numbers, decimals in an integer field, epoch number where an ISO timestamp is planned.
- Why: typed destinations drop or coerce mismatched values, and a warehouse column typed by its first value freezes the wrong type.
- Agent adds: one test payload showing the JSON type, and the tag or code that builds the value (a quoted GTM variable such as `'{{DLV - value}}'` turns numbers into text). Change the plan type only when the plan is wrong for the business fact.
- Default disposition: fix implementation.

## D11 enum-drift

- Condition: observed values outside the allowed list. Needs a values column.
- FAIL: above 1% of rows, or any out-of-list value under policy reject. WARN: otherwise; low sample caps a share FAIL; an untallied tail above 1% that may or may not hold allowed values. INFO: "unknown" counted under send_unknown_and_alert.
- Guards: redaction tokens are printed instead of values; case variants of allowed values are named. The tail (`<other>` beyond the top 50 values, `<rare>` for values seen fewer than 5 times when there are more than 50) counts as out of list when every allowed value is already listed, and is reported as a range otherwise. Part C of the GA4 SQL, Query 2c of the Segment SQL and Query 4 of the PostHog SQL write the tail; a values list without it understates free-text floods.
- Agent adds: a legitimate new value owned by the product amends the plan as a compatible change, after the enforcement copies (Protocols, Amplitude Data, Avo, RudderStack) list it, or they will block or flag it. A value the emitter maps wrongly is an implementation fix.
- Default disposition: needs decision.

## D12 volume-and-duplicates

- Condition: a reference file is given, or the inventory carries duplicate occurrence keys.
- Reference file columns ([../assets/reference-counts-template.csv](../assets/reference-counts-template.csv)): event (the canonical plan event name, never a tool's final name such as GA4 `sign_up`), tool, route (server or browser), count, band_low and band_high (ratios such as 0.85), source_of_truth, window_start, window_end, timezone, tool_timezone and note.
- Route: backend, api, http, measurement_protocol, server-side and sgtm read as server; web, client, gtm, gtag, ios and android read as browser. Any other value, or an empty cell, prints NOT CHECKED with the ratio, because the route decides which threshold applies.
- FAIL: ratio above 1.0; server route below 0.99; any repeated occurrence key on a tier 1 event. WARN: browser route outside its documented band; repeated keys on other events; above 1.0 but inside a documented band above 1.0; a ratio from 0.99 to 1.01 when the time zones are not confirmed to match; a server-route shortfall while the window is provisional. INFO: a ratio with no band; transport duplicates. A ratio with no band is reported, not judged, and the D12 PASS counts it apart from the ratios that match.
- Why: more events than facts come only from duplicates, a second producer or a different definition.
- Before any D12 FAIL: confirm both sides cut days in the same time zone and the refresh window has passed. Fill `timezone` (the reference) and `tool_timezone` (the inventory) in the reference file; the script prints both and adds a NOT CHECKED line when either is missing. GA4 `event_date` and table suffixes follow the property time zone while `event_timestamp` is UTC (the GA4 SQL shows a UTC filter); the Amplitude Export API selects UTC hours; the Segment SQL here uses UTC; Mixpanel projects created before January 2023 export project-time timestamps; system-of-record counts are usually UTC.
- Transport duplicates: Mixpanel deduplicates at query time on event, distinct_id, time and `$insert_id`, so its reports drop repeats while raw exports and warehouse copies keep them. Amplitude ignores a repeat of the same device_id and insert_id within 7 days at ingestion, so its exports rarely hold them. A retry that sends a new insert ID gets past both and shows as a repeated occurrence key instead.
- Agent adds: the same window and exclusions on both sides, and which producer lacks the planned properties. A ratio near 1.00 is not proof of health, because duplicates and losses offset: 5% of each nets to 1.00. When the reference carries IDs (orders, invoices, users), count missing and unexpected keys separately; purchase-reconciliation does this order by order.
- Default disposition: fix implementation. Confirm: the reference query with its exclusions and time zone, and the second producer's location.

## D13 pii

- Condition: a property named like personal data (unless every row is boolean), `pii_hits` above 0 on any property including vendor parameters (emails, phone numbers, also inside free text, and URLs with sensitive query keys), or email-like identifier values.
- FAIL, always. Values are never printed.
- Needs `pii_hits` for the pattern part: a tool with no property rows, or none with `pii_hits`, prints NOT CHECKED, and a PASS then says it covered names only.
- Default disposition: fix implementation first; the privacy owner decides on deletion through each vendor's process. Confirm with the emitter location, never by pasting values.

## D14 limits-and-identifiers

- FAIL: the plan maps more parameters than the limit to one GA4 event; a planned GA4 name over 40 characters that is still observed; placeholders at 1% or more; numeric IDs above 2^53; short IDs in an upstream copy (Segment or RudderStack) of events the plan routes to Amplitude; an identifier the plan names with no null rule missing on 1% or more of a tier 1 event, or on any row of a tier 1 server event. WARN: a GA4 row at the custom parameter limit; placeholders under 1%; short IDs elsewhere; the named identifier missing on other events or on a companion route.
- Placeholders: null, undefined, 0, -1, guest, anonymous, none, nil, unknown, n/a, na, {}, nan, [object Object], lmy47d, the all-zero UUID and the empty string. In a tool that keeps it, the all-zero device ID merges every opted-out device into one profile.
- Amplitude: it removes user and device IDs shorter than 5 characters from the event at ingestion (`min_id_length` can lower that), answers its invalid IDs (anonymous, nil, none, null, n/a, na, undefined, unknown, the empty string, the all-zero UUID, {}, lmy47d, 0 and -1) with a 400 error, and drops all-zero device IDs. So its own export shows a null user_id where a short ID was sent. Check short and invalid IDs in the upstream copy (Segment Query 4 or CDP payloads) for the events routed to Amplitude, and read the null user_id share in the Amplitude export (INFO on the total, D14 per event).
- Null rule: the profiler, GA4 Part F and Segment Query 4 write identifier classes per event. For each event whose identifiers cell names user_id, account_id or workspace_id (or organization, company or group IDs) with no null qualifier such as "user_id (null for system)" or "workspace_id null at this step", the null plus placeholder share is judged by the identifier threshold above. Events sent before identify, server events without account context and B2B events without workspace_id show here; split or wrongly merged users go to identity-merge-qa.
- GA4 parameter count: Part A counts custom keys only, leaving out the keys GA4 adds itself (vendor-params.txt), because Google does not say those count toward the limit and an export row that shows more than the limit is proof GA4 kept them.
- Default disposition: fix implementation, or amend the plan (a shorter name, fewer parameters, a null rule in the contract).

## D15 ga4-registration

- Condition: a planned GA4 parameter (`ga4_parameter`, or the property name) on an active GA4 event that is not registered as a custom dimension or metric, is registered with another scope than the plan's `scope` column, or is registered only as a metric while the plan types it string or boolean. Needs `--ga4-definitions`: a CSV with parameter_name, scope (event, user or item) and kind (dimension or metric), saved from `get_custom_dimensions_and_metrics` on Google's GA4 MCP server or from Admin > Data display > Custom definitions.
- Skipped: identifiers, which stay unregistered for export joins; GA4's own parameters (vendor-params.txt); parameters that fill predefined dimensions or metrics (value, currency, transaction_id, coupon, method, search_term and the rest of the list in the script, taken from the Data API schema).
- FAIL: wrong scope or kind on a tier 1 event. WARN: unregistered; wrong scope or kind elsewhere. NOT CHECKED: GA4 audited without the definitions file.
- Why: GA4 reports, explorations and audiences can use only registered parameters, and a registration does not fill past data. The BigQuery export keeps every parameter either way, so an export-only parameter is fine once the plan says so.
- Agent adds: whether a GA4 report, audience or key-event breakdown needs the parameter, the custom definition quota from the live limits page, and Google's reserved parameter names on its event naming rules page (`currency`, `user_id`, `session_id` and others cannot be registered) before proposing a registration.
- Default disposition: needs decision (register it, or record it as export-only). Wrong scope or kind: fix the registration in Admin.

## Cross-tool matrix

- Printed when two or more tools are audited: one line per planned property that D07, D10 or D11 flagged in any tool (share present and types per tool), and one line per event with reference counts in two or more tools.
- Read it as a locator. The same defect in every tool points at the emitter (code or dataLayer push); a defect in one tool points at that tool's path (GTM tag, CDP destination mapping, connector rename, tool transformation, GA4 event rule).
- It is a heuristic. In the worked example, `signup_method` is short in both tools for two reasons: one emitter path omits it, and GA4 also has a second producer.

## What the script cannot judge

- Whether a trigger fires on the right state change, consent behaviour, identity merges, and whether a consumer still matters.
- A changed meaning in the same field, such as revenue moving from gross to net, passes every type and enum rule. Only a reconciliation against the system of record and a review of the contract catch it.
- Whether an exception in `decisions.md` still holds, and whether a new enum value is legitimate.
- Those stay with the agent, the owners and the hand-off skills.
