# Event contract fields: what each one decides

Use with `assets/event-contract-template.yaml`. Each section gives the decision the field forces and the rule for filling it.

## Contents

- Business question, consumers and grain
- Trigger and must-not-fire
- Source of truth
- Identifiers
- Properties
- PII exclusions and consent
- Time and delivery
- Owners and status
- Spreadsheet layout

## Business question, consumers and grain

- `business_question` names a decision, not a topic. "Track onboarding" fails. "Decide which setup step to fix first for trial workspaces" passes.
- `consumers` lists at least one metric, investigation, experiment or workflow that reads the event. An event with no consumer goes to a backlog with the question that would justify it.
- An experiment consumer needs an exposure event (which unit saw which variant, and when) and the randomisation unit on every outcome event it reads. Without the unit on the event, the analysis cannot be joined back to the assignment.
- `grain` says what one event represents: one completed job, one persisted object, one user interaction. Two developers reading the grain must agree on the count for the same session.
- The counting rule ("first per workspace", "within 168 hours") belongs to the metric in `metrics.md`, not to the event. An event can fire many times; the metric decides which occurrence counts.

## Trigger and must-not-fire

- Write the trigger as a state change that the source can observe: "persisted", "validated", "committed", "rendered under the visibility rule". Avoid "clicked", "submitted" or "loaded" unless the event is explicitly an intent or UI event.
- List at least two `must_not_fire` conditions, in a fixed order, because item n is tested by case T2.n. The usual ones:
  - the intent step before the fact (button click, modal opened, form submitted)
  - the failure path (validation failed, payment declined, job failed)
  - repeats that are not new facts (page refresh, component re-render, back navigation, success page revisited)
  - delivery retries of an occurrence already sent (always listed)
  - automated maintenance that does not change state (token refresh, scheduled sync with no change)
- If the team needs the attempt, the failure and the success, make three events (`*_started`, `*_failed`, the success name) joined by an attempt or job ID. Use one event with an `outcome` property only when every analysis needs all attempts together, and write that choice down.
- `internal_and_test_traffic`: prefer `flag` (an `is_internal` property fixed at account creation) over suppression, so QA traffic can still be traced in production while metrics exclude it.
- Autocapture (clicks, page views, form interactions recorded without a contract) can feed exploration. It cannot replace a contract: it sees intent steps, not the backend state change, and it has no business definition, owner or must-not-fire list.

## Source of truth

Decision rule, in order:

- The fact is a persisted state change (account created, connection saved, report stored): emit from the backend service that commits it.
- The fact is a payment, refund or subscription state: emit from the billing integration after webhook signature verification, keyed on the business fact rather than the webhook delivery. Rules for that key and the duplicate guards: `ecommerce-and-billing.md`.
- The fact is only observable on the client (an impression, a render, a scroll, time on a step): emit from the browser or app.
- The fact needs browser attribution context (GA4 session and client ID) but is committed on the server: keep the backend event canonical and send a companion event from the browser after the success response, under its own name, carrying the backend `event_id` as a property. The companion gets its own `message_id` (see Identifiers), so no destination drops it as a duplicate of the backend event. Write the reconciliation rule (browser count expected lower because of consent and blockers; alert if higher).
- Never emit the same success name from two sources without a written reconciliation rule. Two producers of one name is the most common cause of a metric that is "about double".

## Identifiers

Keep five kinds of ID distinct, plus the transport key derived from the first. Treating any two as interchangeable breaks retries, repeated actions, companion events or distinct counts.

- `event_id`: one business occurrence. A plain UUID string (36 characters: letters, digits and hyphens), generated once at the source when the fact becomes true, persisted with any queued copy, reused on every retry.
  - When the occurrence already has a business key (a refund ID, a subscription ID plus new state), hash that key into a UUIDv5 instead of prefixing or concatenating it. Mixpanel's import endpoint requires `$insert_id` on every event and rejects one over 36 bytes or with characters other than letters, digits and `-`, so `evt_...` or `sub_123:active` breaks it.
  - Companion emissions of the same occurrence (a browser attribution event, an app UI event, the old and new names during a rename overlap) carry `event_id` as a property, for reconciliation only.
- `message_id`: one emission. `UUIDv5(plan namespace, event_id + ":" + emitted event name + ":" + emitter)`.
  - The emitted name is the name in the emitter's own call, before any connector transformation. The emitter is the `source` value (`web`, `ios`, `android`, `server`, `billing`). Record the namespace UUID once in `decisions.md`.
  - Retries of the same emission produce the same value, so retries still collapse. A companion or a renamed twin produces a different value, so it is never dropped.
  - This is the value each destination's dedupe field receives (`messageId`, `$insert_id`, `insert_id`, HubSpot `uuid`). Never put `event_id` itself there: those keys are not scoped by event name. Segment drops a repeated `messageId` from any source for at least 24 hours, RudderStack for 7 days, and Amplitude a repeated `insert_id` on the same `device_id` for 7 days whatever the event type. A shared key makes whichever emission arrives second disappear without an error.
  - A client SDK that assigns its own `messageId` and keeps it across retries (RudderStack documents this for its SDKs) may keep the default for client-only events. Set the derived value whenever the event has a companion, a rename overlap or a server-side retry loop.
- `anonymous_id`: device or browser identity from the collection SDK before login. Pass it to the backend in signup and login requests so server events can link to pre-login activity. On server-only events with no browser context, mark it not applicable with the reason.
- `user_id`: the actor. A stable internal database ID, never an email or username. Segment, Mixpanel and Amplitude all document problems when a known user ID changes later (a changed email becomes a new person). Null only when the contract allows a system actor.
- `account_id` and `workspace_id`: the business context at event time, sent on the event.
  - On server events, take them from the authenticated session or from the object's own record, never from IDs the client supplies in analytics calls. A client-supplied ID can be stale or forged and silently assigns facts to another customer.
  - Reconstructing them later from the user's current membership assigns history to the wrong account after a team switch.
- Object IDs (`connection_id`, `order_id`, `report_id`, `job_id`): the thing the event is about. A retried delivery keeps the same object ID, `event_id` and `message_id`; a genuine new operation gets a new `event_id` even when the object ID repeats.
- Namespace IDs by environment where needed. Staging user `42` is not production user `42`. Check the minimum length rules of each destination (Amplitude rejects user and device IDs shorter than 5 characters by default).

## Properties

For each property record:

- `type`: string, integer, number, boolean, timestamp (ISO 8601 UTC), array. A boolean is never sent as `"true"`, `1` or `"yes"`.
- `required`: true or false. Required means the event is invalid without it, so enforcement tools may reject it.
- `allowed_values` for enumerations, or a `format` (regex, range) for free values.
- `unknown_value_policy` on every property: what happens when production sends a value not in the list, or for a non-enumerated property, a value outside the format. Choose `reject`, `send_unknown_and_alert` or `allow_and_alert`. Never map an unknown value silently onto an existing one; that creates believable wrong reports.
- `null_means` on every property: not applicable, not yet known, or withheld. These are different states and an outage must not look like a zero. For a required property write "never null; rejected".
- `unit` for every quantity: `duration_ms`, `amount_minor` (cents), `size_bytes`. Put the unit in the name when the name would otherwise be ambiguous.
- Money: an integer in minor units or a decimal in major units, stated once for the plan, plus a separate ISO 4217 `currency` property, plus the business definition (gross or net of refunds, tax and shipping included or not, one-off or recurring). Use a vendor's reserved money field (Segment `revenue`, GA4 `value`) only with its documented meaning; keep a separately named business field when your definition differs.
- Scope: event property (the moment), user trait (the person, current), account trait (the organisation, current). If an analysis needs the plan tier at the time of the event, send it on the event as well; the current trait answers a different question.
- Shared properties: when a property appears in two or more contracts, define it once in `martech-context/data-dictionary.md` and reference it by name. The shared definition carries the type, policy and `null_means`. Two definitions of `plan_tier` drift within a quarter.

## PII exclusions and consent

- List what must never reach analytics destinations, even when the source has it: emails, names, phone numbers, postal addresses, free text (search terms, notes, raw error messages), credentials and tokens, connection strings, full URLs with query strings.
- Raw error text is the most common leak: provider error bodies often echo the customer's input. Send a controlled `error_category` instead.
- Google's policy forbids sending GA4 any data Google could use or recognise as personally identifiable. GA4's separately configured user-provided data collection is not permission to put personal data in event parameters.
- An analytics `user_id` is not marketing permission. Record the consent category that gates each event, then fill the consent block:
  - `denied_behaviour` names every destination and what it receives when consent is denied. "Browser route not sent" is incomplete when a server route also forwards the event.
  - `server_side_enforcement`: server events bypass the browser consent platform. Write where the server reads the stored consent state (sent with the signup or login request and stored with the user) and which destinations it filters.
  - `ga4_consent_mode`: `basic` means Google tags do not load and nothing is sent to Google while consent is denied; `advanced` means tags send cookieless measurements while denied. A test that expects "no GA4 request" is wrong under advanced mode.
  - `withdrawal_propagation`: how a withdrawal reaches each route and from which event. Data already sent is handled by the privacy owner's deletion process, not by the plan.
- Mark `privacy_review: required` when a field could identify a person or when consent behaviour changes.

## Time and delivery

- `occurred_at`: when the fact became true, on the source clock, UTC. Generated with the event, not at send time.
- `received_at`: assigned by the ingestion layer. Never accept it from the client.
- `late_arrival_policy`: reports count by `occurred_at` and restate after refresh; workflows (emails, CRM routing, ad conversions) set a maximum lateness after which they do not act.
- `delivery.dedupe_rule`: in the warehouse, first drop transport duplicates on `message_id`, then keep one canonical row per event name plus `event_id`. A rule of "one row per `event_id`" alone merges a companion or renamed event into the canonical one when both land in one table. Do not assume each destination deduplicates with the same key or window.
- `dual_emission`: during a migration, write the overlap window and how each metric avoids counting both names (normally by deduplicating on `event_id` across the two names).

## Owners and status

- `business_owner` decides meaning. `implementation_owner` emits and delivers. `data_owner` maintains counting rules and monitoring. One person can hold several roles in a small team; each role still gets a name.
- Status moves `proposed` to `approved` (owners agreed, tests written), `implemented` (code merged), `verified` (acceptance tests passed with evidence), then `deprecated` and `retired`.

## Spreadsheet layout

When the team works in a sheet instead of YAML files, use two tabs, joined by event name:

- Events tab: one row per event (see `assets/plan-sheet-events.csv` for the columns). The `delivery_paths` column lists each destination with its `via`, so the collision check can group by destination and final name across paths.
- Properties tab: one row per event and property (see `assets/plan-sheet-properties.csv`), with `unknown_value_policy` and `null_means` filled on every row.
- Do not nest a full schema inside one cell. Put destination names in separate columns so a collision check can run over them (`scripts/check_plan.py` reads both tabs).
