# Worked example: three of the six contracts for the fictional Acme Reports
# journey in example-acme-journey.md. All names, IDs and values are invented.
# In a real project each document below is its own file, proposed for:
#   martech-context/tracking-plan/<event>.yaml
# Plan namespace for message_id (recorded in decisions.md):
#   6f0c2a1e-0000-5000-8000-000000000001
---
event: user_registered
display_name: User Registered
version: 1
status: proposed
risk_tier: 1                       # feeds a GA4 key event and ad-platform reporting
journeys: [trial-to-first-report]

business_question: >
  Which acquisition sources produce trial signups that go on to reach a first report.
consumers:
  - type: metric
    ref: trial_signups_by_source_v1
  - type: workflow
    ref: GA4 key event sign_up, imported to ad platforms by the paid media owner
grain: One newly persisted person record

trigger: >
  The identity service commits a new user record and returns success to the client.
must_not_fire:
  - Signup form submitted but rejected by validation
  - Existing user logs in, including through SSO
  - Invited member accepts an invitation (that is member_invitation_accepted)
  - The same registration request is retried with the same idempotency key
  - Success page refreshed or revisited
  - Delivery retry of an event already sent
internal_and_test_traffic: flag

source_of_truth:
  system: backend
  component: identity service
  reason: Only the identity service knows the record was committed
  other_emitters: >
    Companion. The browser pushes GA4 sign_up to the dataLayer after it
    receives the success response, carrying the backend event_id as a
    parameter. GA4 needs the browser client_id and session for attribution.
    The companion has its own name and emitter, so it never shares a
    transport dedupe key with user_registered. Reconciliation: daily GA4
    sign_up is expected to be lower than backend user_registered (consent,
    blockers); alert when the ratio goes above 1.0.

identifiers:
  event_id: Plain UUID created by the identity service at commit; returned to the browser in the success response
  message_id: UUIDv5(plan namespace, event_id + ":user_registered:server"); the same on every Segment server library retry
  anonymous_id: Segment anonymousId from the browser, sent in the signup request so the server event links to pre-signup activity
  user_id: usr_3001 style internal id; never the email address
  account_id: acct_1001 style id created in the same transaction
  workspace_id: null at this step (created by workspace_created)

properties:
  signup_method:
    type: string
    required: true
    allowed_values: [email_password, google_sso, microsoft_sso]
    unknown_value_policy: reject
    null_means: never null; rejected
  is_internal:
    ref: data-dictionary.md#is_internal    # boolean, required, fixed at account creation; unknown values and null rejected

pii_exclusions:
  - Email address, first and last name, company name typed in the form
  - Password and any SSO token
  - Signup page URL query string (can carry an email from a marketing link)
consent:
  category: analytics
  ga4_consent_mode: advanced
  denied_behaviour: >
    Warehouse row still created from the backend record. Amplitude receives
    neither user_registered nor the anonymous_id link. GA4 receives sign_up
    only as a cookieless request carrying the denied state. The browser
    Segment source sends nothing.
  server_side_enforcement: >
    The signup request carries the consent state from the consent platform.
    The identity service stores it with the user and, when analytics is
    denied, sets the Segment integrations object on the server call to
    exclude Amplitude.
  withdrawal_propagation: >
    A withdrawal updates the stored state; the next server event for that
    user excludes Amplitude and the consent update stops GA4 cookies on the
    page. Data already sent is handled by the privacy owner's deletion process.

time:
  occurred_at: Commit time in the identity service, UTC
  received_at: Assigned by Segment and the warehouse loader
  late_arrival_policy: Included in event-time reports after the next refresh
delivery:
  retry: Segment server library retries reuse event_id, message_id and occurred_at
  dedupe_rule: Warehouse drops transport duplicates on message_id, then keeps one row per event name plus event_id
  dual_emission: none for the canonical stream; GA4 sign_up is a companion with its own name

destinations:
  warehouse:
    name: user_registered
    via: segment
  ga4:
    name: sign_up                  # recommended event; meaning matches a completed registration
    via: gtm
    datalayer_push: >
      After the success response, once per event_id (the page keeps the
      event_ids it has pushed this session and skips a repeat):
      dataLayer.push({event: "sign_up", method: "<signup_method>", event_id: "<event_id>"}).
      A GTM GA4 event tag on the custom event sign_up reads method and
      event_id as data layer variables and sends them to G-XXXXXXXXXX.
    parameters: {method: signup_method, event_id: event_id}
    key_event: true                # created and marked in Admin before the first release
    dedupe_mapping: none; GA4 has no general event dedupe, so the once-per-event_id guard on the page prevents repeats. event_id is not registered as a custom dimension
    use: Acquisition reporting and ad-platform conversion import
  amplitude:
    name: user_registered
    via: segment
    dedupe_mapping: Segment Amplitude (Actions) Log Event, Insert ID mapped from messageId; value confirmed on one received event
    identity: Device ID from the default coalesce(context.device.id, anonymousId), which carries the browser anonymous_id; User ID from userId in the same call
    time: Timestamp mapped from timestamp, sent as occurred_at
    use: Signup to first report funnel
  segment:
    name: user_registered
    via: direct_sdk                # server library
    dedupe_mapping: messageId set from message_id
    event_version: 1
    identify_traits: [signup_method]   # partial updates; trait optional in Protocols
  hubspot:
    send: none                     # the contact comes from the signup form integration

owners:
  business_owner: Growth lead
  implementation_owner: Identity team
  data_owner: Analytics lead
privacy_review: required

evidence_sources:
  emission: identity service log line with event_id and message_id; browser network request for the GA4 call
  collection: Segment server source debugger
  destination: Amplitude User Look-Up with the received Insert ID; GA4 DebugView
  warehouse: analytics.user_registered
  use: trial_signups_by_source_v1 and the GA4 sign_up key event count

acceptance:
  - id: T1
    case: New visitor jane@example.com signs up with email and password
    evidence:
      emission: one server call user_registered; one dataLayer push sign_up with the same event_id
      collection: one Segment message whose messageId equals the derived message_id
      destination: Amplitude one user_registered with Insert ID equal to message_id; GA4 one sign_up with method email_password, reported as a key event
      warehouse: one row, signup_method email_password, account_id acct_1001
      use: trial_signups_by_source_v1 counts one signup under the session source
  - id: T2.1
    case: must_not_fire 1, signup rejected because the password is too short
    evidence: absent in the server log, the dataLayer, the Segment debugger, Amplitude, GA4 DebugView and the warehouse; metric unchanged
  - id: T2.2
    case: must_not_fire 2, existing user logs in with Google SSO
    evidence: user_registered and sign_up absent at every layer; metric unchanged
  - id: T2.3
    case: must_not_fire 3, invited member accepts an invitation
    evidence: user_registered and sign_up absent at every layer; member_invitation_accepted present in the Segment debugger and the warehouse
  - id: T2.4
    case: must_not_fire 4, client retries the signup request with the same idempotency key after a timeout
    evidence: one user record; one server call, one Segment message, one Amplitude event, one warehouse row; one dataLayer push
  - id: T2.5
    case: must_not_fire 5, success page refreshed twice
    evidence: no new server call; no second dataLayer push because the event_id guard holds; GA4 DebugView shows one sign_up; one warehouse row
  - id: T2.6
    case: must_not_fire 6, Segment server library resends after a forced timeout
    evidence: two sends with one message_id in the server log; one message in the Segment debugger; one Amplitude event; one warehouse row; metric unchanged
  - id: T3
    case: Two different visitors sign up one after the other
    evidence: two server calls, two Segment messages, two Amplitude users, two GA4 sign_up events and two warehouse rows, with different event_id and user_id values; metric counts two
  - id: T4
    case: Visitor arrives from a marketing link whose query string carries an email address, then signs up
    evidence: the address is absent in every payload, including GA4 page_location, browser Segment page calls, the Amplitude event and the warehouse row
  - id: T5
    case: Companion check, user_registered and sign_up for one signup
    evidence: user_registered arrives in the Segment debugger and Amplitude, sign_up in GA4 DebugView; both carry one event_id; their transport identities differ, so neither is dropped; metric counts one
  - id: T6
    case: Visitor rejects analytics consent, then signs up
    evidence:
      emission: server event carries consent state denied; dataLayer push still made under advanced consent mode
      collection: Segment message with the integrations object excluding Amplitude
      destination: Amplitude has no user_registered and no merge of the anonymous_id; GA4 receives a cookieless sign_up, and Tag Assistant's consent view shows analytics_storage denied
      warehouse: row present
      use: trial_signups_by_source_v1 counts the signup
  - id: T7
    case: Anonymous visitor views three pages, then signs up
    evidence:
      emission: three browser page calls and one server call carrying the same anonymous_id
      collection: Segment web and server sources show the same anonymousId
      destination: Amplitude shows the three page views and user_registered on one user
      warehouse: row carries the browser anonymous_id
      use: source attribution uses the first session's source
changelog:
  - version: 1
    date: "2026-10-17"
    change: Created
    approved_by: ""
---
event: data_source_connected
display_name: Data Source Connected
version: 1
status: proposed
risk_tier: 1                       # feeds the HubSpot owner routing workflow
journeys: [trial-to-first-report]

business_question: >
  Which connector types stop trial workspaces from reaching a usable connection.
consumers:
  - type: metric
    ref: workspace_first_report_7d_v1 (funnel step)
  - type: workflow
    ref: HubSpot owner routing on acme_first_data_source_connected_at
grain: One successfully completed connection operation

trigger: >
  The connection service validates access with the provider and persists the
  connection with status available.
must_not_fire:
  - Connection modal opened
  - Credentials submitted but not yet validated
  - Validation failed (that is data_source_connection_failed)
  - OAuth redirect returned but token exchange not completed
  - Success page refreshed
  - Scheduled token refresh that does not change availability
  - Delivery retry of an event already sent
internal_and_test_traffic: flag

source_of_truth:
  system: backend
  component: connection service
  reason: A browser redirect does not prove the credentials work
  other_emitters: none; the browser sends data_source_connection_started, a different fact, with the same attempt_id

identifiers:
  event_id: Plain UUID created at persistence; reused on retry
  message_id: UUIDv5(plan namespace, event_id + ":data_source_connected:server")
  anonymous_id: not applicable; server event, and the browser intent event carries the anonymous id
  user_id: Acting user from the authenticated session; null when initiated_by is system
  account_id: Account owning the workspace at event time, read from the workspace record on the server
  workspace_id: Workspace owning the connection at event time
  object_ids:
    connection_id: conn_4001 style id
    attempt_id: Created by the browser when the form is submitted; joins to data_source_connection_started

properties:
  connector_type:
    type: string
    required: true
    allowed_values: [postgres, bigquery, google_sheets, csv_upload]
    unknown_value_policy: send_unknown_and_alert   # a new connector ships as unknown and alerts the data owner
    null_means: never null; rejected
  connection_kind:
    type: string
    required: true
    allowed_values: [initial, reconnection]
    unknown_value_policy: reject
    null_means: never null; rejected
  initiated_by:
    type: string
    required: true
    allowed_values: [user, system]
    unknown_value_policy: reject
    null_means: never null; rejected

pii_exclusions:
  - Credentials, access tokens, connection strings, hostnames that name a customer
  - Raw provider error bodies
consent:
  category: analytics
  ga4_consent_mode: not_used       # no GA4 route for this event
  denied_behaviour: >
    Warehouse row still recorded. Amplitude not sent when the acting user's
    stored analytics consent is denied. HubSpot derived properties follow the
    CRM processing decision recorded in decisions.md, not analytics consent.
  server_side_enforcement: >
    The connection service reads the acting user's stored consent state and
    sets the Segment integrations object to exclude Amplitude when analytics
    is denied. System-initiated events are routed per the privacy owner's
    decision in decisions.md.
  withdrawal_propagation: Effective from the next event after the stored state changes

time:
  occurred_at: Persistence time, UTC
  received_at: Ingestion time
  late_arrival_policy: Owner routing ignores events older than 24 hours
delivery:
  retry: Reuse event_id, message_id and occurred_at
  dedupe_rule: Warehouse drops transport duplicates on message_id, then keeps one row per event name plus event_id
  dual_emission: none

destinations:
  warehouse:
    name: data_source_connected
    via: segment
  amplitude:
    name: data_source_connected
    via: segment
    dedupe_mapping: Segment Amplitude (Actions) Log Event, Insert ID mapped from messageId
    identity: User ID from userId; server events have no device id, so system events with a null user_id are not sent to Amplitude
    use: Connection drop-off by connector_type
  segment:
    name: data_source_connected
    via: direct_sdk                # server library
    dedupe_mapping: messageId set from message_id
    event_version: 1
  ga4:
    send: false                    # no acquisition decision uses it
  hubspot:
    send: derived_property
    via: warehouse_sync
    object: company
    match_key: acme_account_id, a unique-value custom company property holding account_id; the sync upserts on it and never matches on domain
    rollup: earliest data_source_connected with connection_kind initial across the account's workspaces
    property: acme_first_data_source_connected_at   # set once; never overwritten
    also: acme_connected_source_count   # available connections summed across the account's workspaces
    use: Owner routing workflow

owners:
  business_owner: Head of Product
  implementation_owner: Integrations team
  data_owner: Analytics lead
privacy_review: not_required

evidence_sources:
  emission: connection service log with event_id and message_id
  collection: Segment server source debugger
  destination: Amplitude User Look-Up with the received Insert ID; HubSpot company record after the next sync
  warehouse: analytics.data_source_connected
  use: workspace_first_report_7d_v1 funnel step and the HubSpot owner routing workflow

acceptance:
  - id: T1
    case: First postgres connection in workspace ws_2001 (account acct_1001) succeeds
    evidence:
      emission: one call with event_id and message_id
      collection: one Segment message
      destination: one Amplitude event with Insert ID equal to message_id; HubSpot company with acme_account_id acct_1001 gets acme_first_data_source_connected_at and acme_connected_source_count 1
      warehouse: one row, connection_kind initial, initiated_by user, workspace_id ws_2001
      use: funnel step counts ws_2001; owner routing fires once
  - id: T2.1
    case: must_not_fire 1, connection modal opened and closed
    evidence: absent in the service log, the Segment debugger, Amplitude, HubSpot and the warehouse; funnel unchanged
  - id: T2.2
    case: must_not_fire 2, credentials submitted while validation is still running
    evidence: absent at every layer until validation completes; one event after it succeeds
  - id: T2.3
    case: must_not_fire 3, wrong password submitted
    evidence: data_source_connected absent at every layer; one data_source_connection_failed with the same attempt_id and a controlled error_category
  - id: T2.4
    case: must_not_fire 4, OAuth consent screen returns to the app but the token exchange fails
    evidence: data_source_connected absent at every layer; data_source_connection_failed with a controlled error_category
  - id: T2.5
    case: must_not_fire 5, success page refreshed
    evidence: no new service call; one Segment message, one Amplitude event and one warehouse row in total
  - id: T2.6
    case: must_not_fire 6, scheduled token refresh succeeds with no change in availability
    evidence: absent at every layer; HubSpot properties unchanged
  - id: T2.7
    case: must_not_fire 7, Segment server library resends after a forced timeout
    evidence: two sends with one message_id in the service log; one Segment message; one Amplitude event; one warehouse row; owner routing fires once
  - id: T3
    case: Broken connection reconnected by the user
    evidence: one new event with a new event_id and connection_kind reconnection at every layer; metric still counts ws_2001 once; acme_first_data_source_connected_at unchanged
  - id: T4
    case: System reconnects automatically
    evidence: warehouse row with user_id null and initiated_by system; no Amplitude event; Segment message present
  - id: T5
    case: User connects in ws_2001, then switches to ws_2002
    evidence: the Segment message, Amplitude event and warehouse row all carry ws_2001
  - id: T6
    case: Connection string with a customer hostname is present in the service while the event is built
    evidence: hostname and connection string absent in every payload (Segment debugger, Amplitude, warehouse, HubSpot)
  - id: T7
    case: User with analytics consent denied connects a source
    evidence: Segment message carries the integrations object excluding Amplitude; no Amplitude event; warehouse row present; HubSpot follows the decisions.md rule
  - id: T8
    case: Accounts acct_1001 and acct_1002 both use the email domain example.com and each connects a source
    evidence: two HubSpot companies, one per acme_account_id, each with its own acme_first_data_source_connected_at; no merge
  - id: T9
    case: Warehouse sync re-run with no new events
    evidence: no new HubSpot company; property values unchanged
  - id: T10
    case: A second workspace ws_2003 in acct_1001 connects a source two days later
    evidence: warehouse row for ws_2003; HubSpot acme_first_data_source_connected_at keeps the earlier ws_2001 value; acme_connected_source_count becomes 2
changelog:
  - version: 1
    date: "2026-10-17"
    change: Created
    approved_by: ""
---
event: report_generated
display_name: Report Generated
version: 1
status: proposed
risk_tier: 2
journeys: [trial-to-first-report]

business_question: >
  Whether trial workspaces produce a first report from their own data within
  seven days, and which setup step precedes failure.
consumers:
  - type: metric
    ref: workspace_first_report_7d_v1 (numerator, first event per workspace with customer_data)
  - type: workflow
    ref: Stop the setup reminder sequence for the workspace
grain: One completed report generation job

trigger: >
  The report job completes, the report is stored and it can be opened.
must_not_fire:
  - Job queued or started
  - Job failed
  - Internal retry inside the job before it succeeds (only the final success fires)
  - Existing report opened, viewed or exported
  - Delivery retry of an event already sent
internal_and_test_traffic: flag

source_of_truth:
  system: backend
  component: report service job runner
  reason: Only the job runner knows the output was stored
  other_emitters: none

identifiers:
  event_id: Plain UUID created when the job reaches success
  message_id: UUIDv5(plan namespace, event_id + ":report_generated:server")
  anonymous_id: not applicable; sent from the job runner, which has no browser or device identity
  user_id: Requesting user; null for scheduled jobs
  account_id: At event time, read from the workspace record by the job runner
  workspace_id: At event time
  object_ids:
    report_id: rpt_5001 style id
    generation_job_id: Job id; one event per job

properties:
  report_type:
    type: string
    required: true
    allowed_values: [dashboard, table, chart]
    unknown_value_policy: send_unknown_and_alert
    null_means: never null; rejected
  input_data_kind:
    type: string
    required: true
    allowed_values: [customer_data, demo_data]
    unknown_value_policy: reject   # the metric numerator depends on it
    null_means: never null; rejected
    description: demo_data when every source is the bundled sample dataset
  generation_kind:
    type: string
    required: true
    allowed_values: [manual, scheduled]
    unknown_value_policy: reject
    null_means: never null; rejected
  duration_ms:
    type: integer
    required: true
    format: integer, 0 or more
    unknown_value_policy: reject values outside the format
    null_means: never null; rejected
    unit: milliseconds from job start to stored output

pii_exclusions:
  - Report title and contents
  - Query text
consent:
  category: analytics
  ga4_consent_mode: not_used
  denied_behaviour: Warehouse row only; Amplitude not sent; HubSpot derived property follows the decisions.md rule
  server_side_enforcement: The job runner reads the requesting user's stored consent state and excludes Amplitude when analytics is denied; scheduled jobs follow decisions.md
  withdrawal_propagation: Effective from the next event after the stored state changes

time:
  occurred_at: Job success time, UTC
  received_at: Ingestion time
  late_arrival_policy: Counted by event time; the reminder workflow acts only on events received within 2 hours
delivery:
  retry: Reuse event_id, message_id and occurred_at
  dedupe_rule: Warehouse drops transport duplicates on message_id, then keeps one row per event name plus event_id
  dual_emission: none

destinations:
  warehouse:
    name: report_generated
    via: segment
  amplitude:
    name: report_generated
    via: segment
    dedupe_mapping: Segment Amplitude (Actions) Log Event, Insert ID mapped from messageId
    identity: User ID from userId; scheduled jobs with a null user_id are not sent
    use: Time to first report by connector
  segment:
    name: report_generated
    via: direct_sdk                # server library
    dedupe_mapping: messageId set from message_id
    event_version: 1
  ga4:
    send: false
  hubspot:
    send: derived_property
    via: warehouse_sync
    object: company
    match_key: acme_account_id (same unique-value property as data_source_connected)
    rollup: earliest report_generated with input_data_kind customer_data across the account's workspaces
    property: acme_first_customer_report_at
    use: Sales follow-up timing

owners:
  business_owner: Head of Product
  implementation_owner: Reporting team
  data_owner: Analytics lead
privacy_review: not_required

evidence_sources:
  emission: job runner log with event_id and message_id
  collection: Segment server source debugger
  destination: Amplitude User Look-Up with the received Insert ID; HubSpot company record after the next sync
  warehouse: analytics.report_generated
  use: workspace_first_report_7d_v1 and the setup reminder sequence state

acceptance:
  - id: T1
    case: First report from a postgres source in ws_2001, 30 hours after workspace creation
    evidence:
      emission: one call with event_id and message_id
      collection: one Segment message
      destination: one Amplitude event with Insert ID equal to message_id; HubSpot acme_first_customer_report_at set on acct_1001
      warehouse: one row, input_data_kind customer_data, workspace_id ws_2001
      use: metric counts ws_2001 once; reminder sequence stops
  - id: T2.1
    case: must_not_fire 1, job queued and started, still running
    evidence: absent in the job log, the Segment debugger, Amplitude and the warehouse; reminder continues
  - id: T2.2
    case: must_not_fire 2, job fails
    evidence: absent at every layer; reminder continues
  - id: T2.3
    case: must_not_fire 3, job retries internally twice, then succeeds
    evidence: exactly one event at every layer, with one event_id
  - id: T2.4
    case: must_not_fire 4, user opens and exports an existing report
    evidence: absent at every layer; metric unchanged
  - id: T2.5
    case: must_not_fire 5, Segment server library resends after a forced timeout
    evidence: two sends with one message_id in the job log; one Segment message; one Amplitude event; one warehouse row
  - id: T3
    case: Report built only on the sample dataset
    evidence: one event with input_data_kind demo_data at every layer; metric does not count it; reminder continues
  - id: T4
    case: Two reports generated in the window
    evidence: two events with different event_id values at every layer; metric still counts the workspace once
  - id: T5
    case: Report title contains a customer's name and the query text contains an email address
    evidence: title and query text absent in every payload (Segment debugger, Amplitude, warehouse)
  - id: T6
    case: Boundary inside, first customer report 167 hours 59 minutes after workspace creation
    evidence: event recorded at every layer; metric counts the workspace
  - id: T7
    case: Boundary at the end, first customer report exactly 168 hours 0 minutes after workspace creation
    evidence: event recorded at every layer; metric does not count it because the window end is exclusive; workspace stays in the denominator
  - id: T8
    case: Scheduled job regenerates a report
    evidence: warehouse row with user_id null and generation_kind scheduled; no Amplitude event; Segment message present
changelog:
  - version: 1
    date: "2026-10-17"
    change: Created
    approved_by: ""
