> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Analytics Event Taxonomy

> The canonical naming grammar for PostHog product events — every new event must pass these rules

Every PostHog event name is an append-only contract: dashboards, funnels, and
cohorts are built against the exact string, so a sloppy name is effectively
permanent. This page is the rule set a new event must pass **before** it ships,
plus the catalog of what exists today. The backend catalog with per-event
property details lives in the [KPI runbook](https://github.com/soundchecklive/soundcheck-mobile-app/blob/dev/docs/posthog-kpi-runbook.md)
(§5); event names and base properties are defined in
`api-events/src/observability/analytics.ts` (`trackProductEvent` /
`trackBillingEvent`).

The goal is that an agent (or engineer) can **rederive an event's name from the
code that emits it** — `AcceptInvitation` ⇒ `invitation_accepted` — and infer
what an event tracks from its name alone, with no tribal context.

***

## The grammar

1. **Shape: `<entity>_<past-participle>`, snake\_case.** The entity noun is the
   **API/domain noun** (`invitation`, `ledger_item`, `crew_offer`,
   `subscription`), never the UI label (`invite`, `finance item`, `gig`).
   UI vocabulary varies by surface and era; the domain noun doesn't.
2. **One emitter per action.** Server-backed state transitions are emitted by
   the **backend only**, at the service-layer transition
   (`api-events/src/observability/analytics.ts`). Clients must never re-capture an API call's
   outcome — the backend event already fired on that exact path, and the
   duplicate double-counts. Clients own only interactions with **no**
   server-side state transition (views, navigation, flow progress,
   client-only telemetry).
3. **Paired outcome events, not an outcome property.** Symmetric outcomes are
   two events (`invitation_accepted` / `invitation_declined`), never one event
   with a `response` property. Funnels and conversion insights need distinct
   event names. A single crew-offer respond call that both accepts and declines
   days emits **both** `crew_offer_accepted` and `crew_offer_declined` (with
   `is_mixed_response` / day-count properties) — still paired names, not one
   `crew_offer_responded` event.
4. **Verbs come from the registered vocabulary.** Current set: `created`,
   `updated`, `deleted`, `viewed`, `sent`, `accepted`, `declined`, `published`,
   `cancelled`, `completed`, `settled`, `filled`, `reopened`, `reversed`,
   `initiated`, `started`, `abandoned`, `failed`, `voided`, `generated`,
   `revoked`, `copied`, `lost`, `added`, `synced`, `rejected`, `requested`,
   `applied`, `delivered`, `clicked`, `complained`, `saved`, `answered`,
   `selected`, `revised`, `reviewed`, `inspected`, `shown`, `withdrawn`,
   `confirmed`, `opened`, `cleared`, `dismissed`, `ended`, plus the phrasal `signed_up` / `signed_in`. Need a new verb? Add it to
   `REGISTERED_VERBS` in `scripts/analytics/taxonomy-check.mjs` **and** here in
   the same PR — don't coin one silently. CI fails on unregistered verbs.
5. **Observation events are a distinct class.** Periodic or stateless metrics
   (`network_density_snapshot`, `customer_service_unavailable`,
   `clock_contract_snapshot`, `ota_update_check`) take no
   action verb. Name them `<metric>_snapshot` or `<condition>`, document them
   as non-action events, and never mix them into action funnels.
6. **Lifecycle families read as state machines.** A family shares one entity
   prefix and its events map 1:1 to state transitions
   (`event_created → event_published → event_completed → event_settled`, with
   `event_cancelled`/`event_reopened` as branches). Parallel families stay
   isomorphic: `invitation_*` and `crew_offer_*` intentionally mirror each
   other (`sent`/`created` → `accepted`|`declined`).
7. **Renames are add-new → migrate dashboards → retire-old.** Never rename in
   place; the old string keeps historical data.

## Property conventions

* **IDs are `<entity>_id`**, matching the domain noun: `invitation_id`,
  `crew_offer_id`, `setlist_id`, `task_id`, `ledger_item_id`. `event_id`
  always means the gig/event entity.
* **`org_id`, never `organization_id`.** The backend base properties
  (`trackProductEvent` in `api-events/src/observability/analytics.ts`) set the standard:
  `org_id`, `user_id`, `event_id`, `source`, `environment`.
* **`source` is reserved for provenance.** Base value is `"backend"`. The one
  sanctioned override: `payment_settled`/`payment_reversed` set `source` to the
  payment origin (`manual`, `stripe_payment_intent`, `stripe_invoice`,
  `stripe_refund`, `cleo_ach`) — GMV insights filter on it, so it is load-bearing and
  documented in the runbook. Mobile `update_gate_shown`, `update_gate_store_opened`,
  and `update_gate_cleared` use `source: v1|v2` for the policy controller that owns the gate.
  Do **not** add other meanings for `source`; use a
  specific key instead (`creation_source`, `surface`, …).
* **IDs only — never email, phone, or free text** in properties (PII stays out
  of the analytics pipeline; see `has_decline_reason` on `invitation_declined`
  for the pattern: a boolean about the text, not the text).

## Who emits what

Mobile update telemetry includes `ota_update_check` with `result`,
`ota_update_applied` with `from_update_id`, `to_update_id`, and `deferred_ms`,
and `ota_update_failed` with `stage` and `error`. Application is reported after
the new bundle starts with the persisted target ID. The three `update_gate_*`
events carry `kind`, `platform`, `installed`, `minimum`, and controller `source`.
`update_gate_cleared` (v1 and v2) also fires on the first launch after a store update
that reaches the prompted `minimum`, with the properties of the gate that launch no
longer shows, so `installed` is the version that was prompted. A disabled or
lowered policy does not report it while the build is still below that minimum.
Every mobile event also carries `update_id`, `update_channel`, and
`update_is_embedded`, so usage and errors can be split by the running bundle.

| Tier | Owns | Examples |
| - | - | - |
| **Backend** (`api-events` `trackProductEvent` / `trackBillingEvent`) | Every server-side state transition. Encore (`api-events`) is the live HTTP emitter for these events. See the runbook §5 table. | `organization_created`, `user_signed_up`, `event_*` lifecycle, `event_template_saved`, `event_ingestion_completed`, `invitation_*`, `crew_offer_*`, `ledger_item_created`, `payment_*`, `subscription_*`, `brevo_signal_delivered`, `entitlement_rejected`, `import_*`, `export_generated`, `contract_*`, `quote_requested`, `member_onboarding_step_completed` / `member_onboarding_completed`, observation events |
| **Mobile** (`useAnalytics().capture`) | Client-only interactions | `event_viewed`, `schedule_timeline_viewed` (once per gig visit), `gig_detail_viewed` (once per gig visit; `role`, `phase`), `event_tab_selected`, `event_section_selected`, `gigs_home_viewed` (`view_mode`, `calendar_mode`; once per Gigs-tab focus), `gigs_view_mode_selected` (`from`, `to`, `trigger`), `user_signed_in`, `signup_started`, `onboarding_*`, `org_setup_*`, `calendar_feed_*`, `store_review_prompt_requested` (`trigger`), `auth_session_lost` (`reason`; launch sign-outs add `app_state` (at launch), `app_state_now`, `network_connected`, `network_reachable`, `clerk_status`, `token_read_outcome`, `token_read_ms`, `token_store_async` (true only where the keychain is missing, such as Expo Go), `client_token_read_outcome`, `client_token_read_ms` (the last read of `__clerk_client_jwt` specifically), `token_in_keychain`, `token_in_fallback` (derived from that read's outcome; `null` when the store threw or was not consulted), `client_token_secure_error` (`interaction_not_allowed` = keychain locked or before first unlock, `timed_out`, `other`; the first keychain error of that launch's client-token read, kept even when a retry recovered), `token_restore_outcome`, `token_restore_certain`, `token_read_attempts` (the bounded retry of the first client-token read of the process, kept when a later read recovers; an uncertain read keeps Clerk from overwriting the saved client token), `keychain_accessibility` (`after_first_unlock` once every auth keychain item was re-created under AFTER\_FIRST\_UNLOCK, else `legacy_unverified`; `fallback_store` where the keychain is missing), `keychain_keys_pending` (iOS auth keychain items not yet upgraded; `null` off iOS, where the keychain is missing, or when unknown), `previous_build` and `app_updated` (set when the build changed since the last signed-in launch), `previous_distinct_id`, `distinct_id_at_launch`, `previous_user_id` (the identity recorded before the PostHog reset, because the event itself lands on the post-reset anonymous id; `previous_distinct_id` is only ever an identified id, never an anonymous one), `marker_state` (`valid`, `legacy`, `corrupt`; each loss is reported once: a corrupt marker is then cleared, any other marker is stamped reported), `ms_since_signed_in_launch`, plus the `auth_session_confirmed` token properties below; never sent from a background or inactive launch, which only decides on the first foreground; `auto_logout_401_refresh_failed` adds `endpoint`, `refresh_outcome` (`no_token`, `session_invalid`, `error`), `logout_triggered`, and is sent once per process until a refresh succeeds; a triggered logout runs at most once per launch), `auth_session_confirmed` (once per process when Clerk loads signed in: `app_state` (at launch), `app_state_now`, `clerk_status`, `token_restore_outcome`, `token_restore_certain`, `keychain_token_fp` / `keychain_token_iat` and `fallback_token_fp` / `fallback_token_iat` (the launch read of `__clerk_client_jwt` per store: last 6 hex chars of a SHA-256 of the JWT, and its `iat`; never the token), `last_saved_token_fp` / `last_saved_token_iat` (the last client-token save this process), `keychain_failure_key` (`client_jwt`, `resource`, `other`), `keychain_failure_op` (`read`, `write`), `keychain_failure_error` (`interaction_not_allowed`, `timed_out`, `other`), `keychain_accessibility`, `keychain_keys_pending`), `push_opened`, `payout_status_failed`, plus PostHog-native `$screen` via `posthog.screen()` (the ENG-1045 restore/retry screen reports as `session_restore_retry` with `restore_hold`: `timed_out`, `clerk_degraded`, `token_read_uncertain`) |
| **Web** (`captureEvent` / `lib/analytics/productEvents.ts`) | Client-only interactions and flow progress | `signup_started`, `onboarding_*`, `org_setup_*`, `calendar_feed_*` (incl. `_copied`), `setlist_saved`, `org_invitation_*`, `home_section_viewed` / `home_section_clicked` |

**`event_template_saved` carries `mode`, not two names.** Saving a gig as a
template and saving it *over* an existing template are the same action on the
same entity with one different destination, so the backend emits one event with
`mode: new | new_version` (rule 3 pairs symmetric *outcomes*, which these are
not). It also carries `excluded_keys` — the review choices, as stable keys, never
labels or free text — and `from_plan_id` when the source gig was created from a
plan. `saved` is a registered verb for this event; `setlist_saved` remains the
grandfathered create/update conflation rule 3 wants split.

**`export_generated` carries `export_type`, not one name per document.** Every
server-built download (gig workbook, call sheet, advance packet, day sheet,
finance-items CSV, workspace zip) is the same action — "the server generated an
export" — so one event with `export_type` + `export_format` keeps the export
funnel on a single name. The client must not also capture a download click for
these.

**`sor_write` is the one system-of-record write signal (PRO-402, PRO-651).**
It is an observation event, registered by exact name: a measurement over every
write to gigs, crew demand, shifts, offers, messages and payouts, not a domain
transition, so it does not follow the `<entity>_<verb>` grammar and does not
replace any lifecycle event (`event_created` stays canonical for "a gig was
created"). A Postgres trigger on those tables queues one row in `effect_outbox`
per object per request (one per gig for messages; only `PAYABLE` ledger lines
count as payouts), in the writer's own transaction, so a rolled-back write
never emits and a retried delivery never duplicates (the outbox row id is the
PostHog `uuid`). Writes with no request context (jobs, webhooks, syncs) are
`creator_kind: system` on the row and are not emitted. Properties: `org_id`,
`event_id` (null for payments), `object_type` (`gig`, `crew_demand`, `shift`,
`offer`, `message`, `payout`, `payment`), `object_id`, `operation`
(`create` | `update`; a create and edits in one request are one `create`),
`creator_kind` (`person`, `checkai`, `external_agent`, `import`),
`agent_created` (true for the last three), `rail` (`app`, `chat`, `checkai`,
`mcp`, `import`), `confirm_gated`, `agent_id` and `user_id` (the principal).
Definitions: `docs/engineering/backend/creator-attribution.mdx`.

**`import_completed` / `import_failed` are one emission per import run.** The
entity is the `ImportJob` (`import_id`), not the rows it wrote; row outcomes
travel as counts (`records_created`, `failed_rows`, …). A partial commit is
`import_completed` with `import_status: partial`; `import_failed` means nothing
was written (`failure_stage: rows_rejected`) or the commit threw after it began
(`failure_stage: commit_error`).

**Public-sourced events use the org as the distinct id.**
`quote_requested` (public storefront) has no acting Clerk user, so it passes
`org_id` as the distinct id **and** sets `$process_person_profile: false`, so
PostHog does not create a person for the org. Money on `quote_requested` is in minor units (`estimate_low_cents`) with `currency`.

**`org_invitation_sent` / `org_invitation_failed` are web-owned on purpose.**
Org invites go from the browser straight to Clerk (`organization.inviteMember()`),
so no api-events endpoint is hit and rule 2 has no backend emitter to defer to.
They are distinct from the backend's `invitation_sent` (the crew/gig `Invitation`
entity) and `org_member_added` (a user added to an org through our API). One
emission per submit, with `invite_count` carrying the batch size; a partial batch
emits both, like the mixed-response `crew_offer_*` pair. `reason` is a category,
never Clerk's raw message — those embed invitee emails.

Cross-platform events (`onboarding_*`, `calendar_feed_*`) intentionally share
one name across mobile and web — same action, one emission per occurrence,
different surface. That is the correct pattern; two *names* for one action is
the anti-pattern.

**Event-chat interactions are also cross-platform client events.** Mobile and
web emit `event_chat_viewed`, `event_chat_message_sent`, and
`event_chat_message_failed`. Sending remains client-owned because clients call
the Twilio Conversations SDK directly; api-events only observes the later
provider webhook and must not duplicate the user's send attempt. Each event
includes `chat_id` and, when available, `event_id` and `org_id`. Message bodies,
phone numbers, member names, and other free text are never analytics properties.

**Gigs layout events are mobile-owned.** The Gigs tab layout is a device
preference in AsyncStorage, so no API call changes it. `gigs_home_viewed`
(`view_mode`: `list` | `calendar`, `calendar_mode`: `day` | `week` | `month`)
fires each time the Gigs list comes into view (launch, tab switch, or back from
a gig), after the saved preference and any `?view=` link have been applied, so
it measures the layout people stay on; leaving Gigs before then sends nothing. `gigs_view_mode_selected` (`from`, `to`, `trigger`: `toggle` |
`deep_link`, `calendar_mode`) fires when the layout actually changes; picking
the mode already shown sends nothing. A `?view=` link uses `deep_link`; when it
lands before the saved layout loads, `from` is `null`. `trigger` stands in for
`source`, which stays reserved for provenance.

**`push_preprompt_*` is mobile-owned.** The soft sheet before the OS notification
prompt emits `push_preprompt_shown`, then `push_preprompt_dismissed` (Not now) or
`push_preprompt_accepted` (Turn on), all with `show_count` (1 to 3). Accepting is
followed by `push_preprompt_answered` with the OS answer in `status` (`granted`,
`denied`, `undetermined`, `unavailable`).

**`push_opened` is mobile-owned.** Opening a push notification calls no API, so
the app emits it once per tap, from both the launch (cold start) path and the
running-app listener. It carries `push_type` (the registered `data.type` from
`push-types/registry.json`, or `unknown`), `routed` (whether the tap navigated
to a screen) and `cold_start`. A tap that cannot be routed still emits it with
`routed: false`, so `routed` rate per `push_type` is the delivery-to-screen
health signal.

**Follow live is mobile-owned.** The server audit log keeps
`live_activity_registered` and `live_activity_stop_requested`; these client events
cover what it cannot see, and api-events never re-emits them.
`live_schedule_follow_clicked` (`event_id`, `role`, `platform`, `replacing`:
already following another gig, `surface`: `day` | `full` | `timeline` |
`setlist` | `schedule`) fires on a tap of **Follow live** or **Follow this gig
instead**. `live_schedule_follow_started` (`event_id`, `role`, `platform`,
`trigger`: `tap` | `auto`, `has_live_activity`, `background_updates`:
`connected` | `failed` | `pending` | `not_applicable`, `minutes_to_call`:
minutes from start to the first schedule item, negative once it has begun) fires
once per session, after the first registration result or 30 seconds, whichever
comes first; `not_applicable` means no Live Activity (Android, or an iOS build
without the extension). `live_schedule_follow_ended` (`event_id`, `role`,
`platform`, `duration_ms`, `reason`: `user_stopped` | `switched_gig` |
`signed_out` | `server_ended` (gig cancelled or no longer visible) |
`show_complete` | `expired` (eight hours), `background_updates_ever_connected`)
fires once per session. A session lost to an app kill sends no end event.
`live_schedule_registration_failed` (`event_id`, `platform`, `stage`:
`register` | `token_rotation`) fires at most once per stage per session when
APNs token registration fails; registration keeps retrying while the app is open.

**`widget_opened` is mobile-owned.** Links from the Next gig widget and the
Live Activity carry `?src=widget`, `?src=widget_directions` or
`?src=live_activity` (`lib/navigation/widgetLinks.ts`). Opening one emits
`widget_opened` (`event_id`, `platform`, `target`: `gig` | `directions` |
`live_activity`, `cold_start`) from `app/+native-intent.ts`. Untagged links
to the same gig, from the Gigs list or a push, send nothing.

**Run-of-show and setlist views are mobile-owned.** Reading them calls no
state-changing API. `run_of_show_viewed` (`event_id`, `item_count`, `role`:
`admin` | `member`, the same role as `gig_detail_viewed`) fires once per gig
visit when the Run of Show panel is open and shows items in any view; a
collapsed section counts once it is opened, and an empty run of show sends
nothing. It sits beside `schedule_timeline_viewed`, which covers the
Schedule list, so divide it by `gig_detail_viewed` per `role` for the open rate.
`setlist_viewed` (`event_id`, `setlist_id`, `song_count`, `attachment_count`)
fires each time the full-screen setlist performance viewer opens;
`attachment_count: 0` means the viewer showed its empty state.

**Performance mode sessions are mobile-owned** (`usePerformanceModeCapture`).
`performance_mode_opened` (`event_id`, `role`, `item_count`, `start_index`)
fires once per open, after the run of show has loaded at least one item; an
empty run of show or the flag turned off sends nothing.
`performance_mode_ended` is the one summary per session, sent on close, on
unmount while open and when the gig changes: `event_id`, `role`,
`item_count`, `duration_ms` (foreground time only), `background_ms`,
`items_viewed` (distinct items shown), `max_index_reached`, `reached_end`,
`nav_next_count`, `nav_prev_count`, `jump_count`, `wrapped` (next went from the
last item to the first), `chart_zoom_count`, `chart_load_error_count` (items
whose chart failed to load, each counted once) and `backgrounded_count`. Killing the app while the viewer is open loses `ended`.
`performance_mode_item_viewed` (`event_id`, `role`, `item_index`,
`item_count`, `item_type`, `content_kind`: `image` | `svg` | `pdf` | `video` |
`audio` | `other` | `none`, `nav_method`: `initial` | `next` | `prev` | `jump`
\| `restore`) fires once per item per session after 2 s on screen in the
foreground (the clock pauses while charts load or the jump list covers the
slide), so a trend by `event_id` shows where a show is. An item is its cue plus
its attachment, so a refetch that keeps the same item on screen is not a new
view. A failed chart request counts toward `chart_load_error_count` when an
affected song slide is shown. The `ended` verb also closes
the Follow live family (`live_schedule_follow_ended`).

**Mobile `$screen` names are route templates.** `ScreenTracker`
(`lib/providers/AppProviders.tsx`) names each screen from the Expo Router
segments with groups such as `(tabs)` dropped (`/events/[id]`, not
`/events/<uuid>`). The raw path travels in `path`, and `previous_screen` is the
previous template. Screens from builds before ENG-1422 carry the raw path as
the name.

**Inbox events are mobile-owned surface markers.** `inbox_filter_selected`
(`filter`, `previous_filter`) fires when crew pick a filter from the Inbox
header menu; it calls no API. `mailbox_viewed` (`view: mail | messages`) is
kept under its old name so existing insights keep working.

**Crew responses carry paired `inbox_*` surface markers.** Each successful
answer to an offer, invitation or availability request from the app fires
paired events, all from `lib/analytics/crewResponseCapture.ts`:

| Answer | Events | ID key |
| - | - | - |
| Crew offer | `inbox_offer_accepted` / `inbox_offer_declined` | `crew_offer_id` |
| Invitation | `inbox_invitation_accepted` / `inbox_invitation_declined` | `invitation_id` |
| Availability request | `inbox_availability_request_accepted` / `inbox_availability_request_declined` | `availability_request_id` |

`surface` (`inbox` | `crew_home` | `email_link`) says where the answer was
given; `email_link` is an email reply button that the app submits without a
confirm step. It is a surface key, not `source`. Offer and availability events
also carry `accepted_day_count`, `declined_day_count`, `per_day`,
`is_mixed_response` and `has_note`; offer events add `open_shift` and `inline`
(answered from the card without opening a sheet). `inbox_invitation_declined`
carries `has_note`. With `per_day: true` the day counts come from the server's
per-day results, so a day that filled or had already passed is not counted; a
whole answer counts every day of the reply. A per-day answer that both accepts
and declines fires both events of the pair. Nothing fires when the call fails
or the reply was already answered, and note text is never sent.

Before ENG-1738, `inbox_offer_accepted` fired only for inline card accepts and
had no `inline` property. Insights that measure the inline share filter on
`inline` is not `false`, so older events still count.

These are surface markers, not the outcome counts. The backend
`crew_offer_accepted` / `crew_offer_declined` and `invitation_accepted` /
`invitation_declined` stay canonical; divide the two for a surface's share.
Availability requests have no backend event yet, so until the backend emits an
`availability_request_*` pair, the `inbox_availability_request_*` events are the
only record of availability answers given in the app.

**`payout_status_failed` is mobile-owned client telemetry.** The You payout
setup card emits it each time `GET /v1/users/stripe/connect/status` fails to give
a usable status. The read changes no state, so there is no backend event for it.
It carries `reason` (`http_error` | `network` | `unrecognized_status`),
`http_status` (or `null`), `attempt` (consecutive failures for this card) and
`exhausted` (the retry budget ran out and the card now offers setup again or
support instead of Retry).

**Gig venue events are mobile-owned.** Opening maps or copying an address calls
no API. `venue_logistics_viewed` fires once per gig visit when the gig venue
panel is shown (the Venue tab, the plan's Venue & load-in section, or its own
screen), deduped like `gig_detail_viewed`. It carries `event_id`,
`populated_field_count` (venue access fields with a value: access code, dock,
load-in, load-out, elevator, parking, green room), `has_load_in`,
`has_location` and `role` (`member` | `admin`). `venue_directions_opened`
(`event_id`, `has_coordinates`, `map_app`: `apple_maps` | `google_maps`,
`surface`) fires when a map app is chosen in the maps chooser, never on cancel.
`venue_address_copied` (`event_id`, `surface`) fires from Copy address on the
venue card or in the maps chooser. `surface` is `gig_venue` for the venue
panel's Directions and Copy address, and `gig_detail` for the gig header
address and the Key facts directions button. Field values, venue names and
addresses are never properties.

**The litmus test for a new client event:** does the user action call an API
endpoint that changes state? If yes, the event belongs in the backend service
(and probably already exists). If the backend can't currently distinguish the
context you care about, add a request field and a property to the backend event
(see `creation_source` on `event_created`) — don't add a client-side event.

## Starter packs and usage data

Pack applications are the first-party usage signal for the industry-insights product. Capture is durable (a row in the caller's transaction) and the PostHog event carries counts only.

| Event | Emitter | Properties |
| - | - | - |
| `starter_pack_applied` | Backend (`api-events` `trackStarterPackApplied`, after commit of the apply) | `pack_kind`, `pack_id`, `business_type`, `has_event`, `offered_count`, `included_count`, `created_count`, `skipped_count` |
| `starter_pack_gallery_viewed` | Web (`trackStarterPackGalleryViewed`, once per picker mount; `/setup` and the signup pack step send one per pack kind shown) | `pack_kind`, `source` (`setup`, `empty_state`, `header`, `gig`, `onboarding`) |
| `starter_pack_selected` | Web (`trackStarterPackSelected`, user adds a pack; `/setup` and the signup pack step send it once the add succeeds) | `pack_kind`, `pack_id` |
| `starter_pack_item_revised` | Web (`trackStarterPackItemRevised`, reserved; the picker has no per-item toggle yet) | `pack_kind`, `pack_id`, `included` |

There is no client "applied" event: the backend `starter_pack_applied` is the outcome. Provisioned packs (system apply on org setup) are recorded in `starter_pack_applications` but emit no event.

Tables and views (migration `20261017140100_starter_pack_usage_foundation`):

| Object | Kind | Holds |
| - | - | - |
| `starter_pack_applications` | table | One row per pack apply: `pack_kind`, `pack_id`, `business_type` at apply time, optional `event_id`, offered and included item keys, counts, `created_records` JSON (`entity`, `id`, `item_key`). Cascades with the organization. |
| `usage_venue_gigs` | view | One row per qualifying gig at a venue: venue, org, business type, city key, civil month. |
| `usage_hotel_stays` | view | One row per event hotel stay with at least one qualifying gig (its own `event_id` or a gig linked in `event_hotel_events`): hotel, org, business type, city key, check-in month, room nights. |
| `usage_song_setlists` | view | One row per song placement in a gig setlist (template setlists excluded): normalized title and artist, org, business type, month. |
| `usage_gear_bookings` | view | One row per inventory assignment on a gig: normalized manufacturer and model (else category), org, business type, month, quantity. |
| `starter_pack_item_retention` | view | One row per `created_records` element with `still_exists` and `used`. `used` is NULL where no link exists (offerings, offering templates, setlists, events). |
| `Organization.usage_data_sharing` | column | `aggregate` (default) or `opted_out`. Set by admins through `PATCH /api/v1/organizations/{orgId}`; audited as `organization_usage_data_sharing_changed`. |

A qualifying gig is not a `TEMPLATE`, `DRAFT` or `CANCELLED` event, has no `cancelled_at`, and has no `deletion_scheduled_for`. The views carry `organization_id` so aggregates can count distinct orgs; they are internal and have no public route.

Aggregates (`api-events/src/systems/usage-data/`) read the views through `aggregateUsage(kind, options)`, with kind `venue`, `hotel`, `song` or `gear` and group-by `entity`, `city`, `business_type` or `month`. A row is returned only when at least `USAGE_MIN_ORGS` (5) distinct organizations contribute, and only organizations with `usage_data_sharing = 'aggregate'` are counted. Output carries `entity`, `city_key`, `business_type`, `month`, `orgs` and `count`: never an organization id or name, a person, money, or third-party data. `starterPackAdoption(packKind, { businessType? })` returns adoption per pack id at `STARTER_PACK_ADOPTION_MIN_ORGS` (3) and above; the offerings starter-pack list shows it as `adoption: { orgs, label }`. The aggregate route is private (`/internal/usage-data/aggregates`, `expose: false`).

Provenance (first-party versus third-party). First-party identifiers are ours: venue, hotel, offering, setlist, inventory, organization and event ids; normalized names and categories; civil dates; counts. Third-party data is never output by an aggregate: Google Places ids and photos (`venues.place_id`, `photo_url`), Spotify ids and artwork (song catalog data), and context.dev research source URLs. Song and gear keys are derived from our own normalized text, not from a provider id. The `catalog_key` columns on inventory items and songs, and `is_sample` on events, are not referenced by these views until their PRs land.

## Known legacy quirks (do not repeat; migrate opportunistically)

* **`event_*` collides with the analytics word "event".** The domain entity is
  `Event` (a gig), so `event_created` means "a gig was created". Renaming the
  family would break every dashboard, so `event_*` stays canonical and `gig_*`
  is retired vocabulary in analytics — never introduce it.
* **`organization_created` vs `org_member_added` / `org_setup_*`.** The prefix
  is inconsistent. Standard going forward: **`org_`** (matches the `org_id`
  property). `organization_created` keeps its name per rule 7.
* **Mobile `finance_item_voided` / `finance_item_deleted`** use UI vocabulary;
  the domain noun is `ledger_item` (and `item_id` should be `ledger_item_id`).
  These are the sole emitters (no backend equivalent yet), so a rename is
  cheap — do it if you touch them, or move the emission to the backend.
* **`setlist_saved {mode: create|update}`** breaks rules 3/4 — it should be
  `setlist_created` + `setlist_updated`. Migrate per rule 7 when the setlist
  funnels are next touched.
* **Signup funnel is two events:** clients emit `signup_started` when the user
  submits credentials / taps SSO (funnel start); the backend emits
  `user_signed_up` from the Clerk `user.created` webhook (completion). Mobile
  still emits `user_signed_in` for login. Do not re-capture `user_signed_up`
  on clients. `user_signed_up` carries `signup_method` and
  `email_is_apple_relay` (the primary email is a Sign in with Apple
  `@privaterelay.appleid.com` address). The same `email_is_apple_relay` is a
  person property, re-set on every Clerk `user.created` and `user.updated`, so it
  turns false once the user makes a real email primary.
  `user_signed_up` and `organization_created` also carry `platform` (`ios`,
  `android` or `web`). Each client writes `platform` into the Clerk user's
  `unsafe_metadata` when it creates a sign-up, and again right before it creates
  an organization. `user_signed_up` reads it from the `user.created` payload;
  `organization_created` reads the creator's current value from Clerk. It is
  absent for app builds older than ENG-1808 and for accounts that never got a
  stamp.
  Mobile sends `signup_started` from every sign-up form (email, invite ticket,
  SMS gig invite) and from SSO on the sign-up screens when tapped. On the sign-in
  screen, Apple and Google send it only when the result created an account, so a
  returning user signing in is not counted.
* **`org_setup_*` is one attempt per run on both clients.** `org_setup_started`
  (`flow`: `owner` or `org_invite`) fires once per real setup attempt, followed
  by at most one `org_setup_completed` (`org_id`, `org_type`,
  `is_first_completion`; owner flow only) or `org_setup_failed` (`reason`,
  `detail`). Web skips it when the bootstrap only re-activates an owner org the
  user already has. Mobile emits it from `finalizeFounderWorkspace` (which wraps
  `ensureOrg`) and `acceptStoredOrgInvite`; it emits nothing when no invite is
  stored.
* **`onboarding_step_back`** lacks a verb; treat it as grandfathered, not as
  precedent.
* **Encore Stripe observations `subscription.trial_will_end` /
  `subscription.invoice_upcoming`** use dotted Stripe-adjacent names that predate
  the snake\_case verb grammar. Grandfathered; still treated as backend-canonical
  so clients must not re-capture them.

## Enforcement

`scripts/analytics/taxonomy-check.mjs` lints every event name across the emitter
tiers — Encore `api-events` `trackProductEvent` / `trackBillingEvent` literal
captures (backend-canonical set for the one-emitter rule), web
`captureEvent`/`trackEvent` call sites, and mobile `capture` call sites. It
fails CI (`Code Quality / Static Checks`) on: non-snake\_case names,
unregistered verbs, retired ENG-530 names, dynamic (non-literal) event names
outside the sanctioned wrapper modules, and any client capture of a
backend-canonical name (the double-count bug). The grandfathered-legacy and
observation-event registries live in the script.

It also lints **statically visible property keys** on those capture sites:

* object-literal second args to web/mobile `captureEvent` / `capture` /
  `trackEvent`
* Encore inner `properties: { ... }` blocks (not the envelope's `distinctId` /
  `orgId` API params)

Rules: keys must be snake\_case (PostHog `$…` keys exempt), and banned aliases
are rejected (`organization_id` / `organizationId` → use `org_id`). Spreads and
variable-held property bags are opaque and skipped — keep literals at the
capture site when you need the lint to see them.

Run it locally with `npm run lint:analytics` (add `--inventory` to print every
event name with its emitter locations). Unit coverage for the property helpers
lives in `scripts/analytics/taxonomy-check.test.mjs`.

## Retired duplicates (ENG-530)

These client events double-counted backend canonicals and were removed. Never
reintroduce them; historical data ends at removal.

| Removed (client) | Canonical (backend) |
| - | - |
| `invite_accepted`, `invite_declined` (mobile) | `invitation_accepted`, `invitation_declined` |
| `event_created` (mobile duplicate capture) | `event_created` (backend; same name — the mobile copy silently double-counted) |
| `gig_created` (web) | `event_created` + `creation_source` property (`manual` \| `lead_conversion`) |
| `invitation_responded` (web) | `invitation_accepted`, `invitation_declined` + `via_signup` property |
| `payout_sent` (web) | `payment_settled` |

Dashboards or insights still keyed on a retired name (e.g. "Invite accept vs
decline rate", "Invitation Response Rate") must be re-pointed to the canonical
event in PostHog.

## Checklist for a new event

1. Is there a server-side state transition? → backend emits it, via
   `trackProductEvent` at the service layer; add the event name to
   `api-events/src/observability/analytics.ts` call sites and a row to the runbook §5 table.
2. Name is `<domain-noun>_<registered-verb>`; outcomes are paired events.
3. Properties: `<entity>_id` keys, `org_id`, no PII, no new `source` meanings.
4. Grep all three tiers for an existing event covering the same action before
   adding one.
5. Event names in code are string literals (Encore `trackProductEvent` first
   argument) or live in a typed helper
   (`web/src/lib/analytics/productEvents.ts`) — no inline ad-hoc strings in
   components when a helper exists.
6. `npm run lint:analytics` passes (CI enforces it — see Enforcement).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.