airbridge-mixpanel-explainer

v4v5 · +21 −1329 · View latest

<title>AirBridge → Mixpanel Mapping — Interactive Guide</title>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>AirBridge → Mixpanel Mapping — v1.3</title>
<style>
  :root {
    --bg: #f2f4f3;
    --panel: #fbfcfb;
    --panel-2: #eef0ef;
    --ink: #14181a;
    --muted: #5c6366;
    --muted-2: #82898b;
    --accent: #1c6e73;
    --accent-strong: #145256;
    --accent-soft: rgba(28,110,115,0.10);
    --accent-soft-2: rgba(28,110,115,0.18);
    --warn: #9a6a08;
    --warn-soft: rgba(154,106,8,0.12);
    --ignore: #7c8386;
    --ignore-soft: rgba(124,131,134,0.10);
    --border: rgba(20,24,26,0.13);
    --border-2: rgba(20,24,26,0.22);
    --code-bg: #101617;
    --code-ink: #d8e4e2;
    --shadow: 0 1px 2px rgba(20,24,26,0.05), 0 12px 28px -14px rgba(20,24,26,0.22);
  }
  :root[data-theme="dark"] {
    --bg: #0e1112;
    --panel: #171b1c;
    --panel-2: #1e2324;
    --ink: #eef1f0;
    --muted: #9aa3a3;
    --muted-2: #7d8686;
    --accent: #5fc7cc;
    --accent-strong: #8adade;
    --accent-soft: rgba(95,199,204,0.13);
    --accent-soft-2: rgba(95,199,204,0.22);
    --warn: #e2ab44;
    --warn-soft: rgba(226,171,68,0.14);
    --ignore: #8b9394;
    --ignore-soft: rgba(139,147,148,0.12);
    --border: rgba(238,241,240,0.14);
    --border-2: rgba(238,241,240,0.24);
    --code-bg: #0a0d0e;
    --code-ink: #cfe3e0;
    --shadow: 0 1px 2px rgba(0,0,0,0.35), 0 12px 28px -14px rgba(0,0,0,0.6);
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --bg: #0e1112;
      --panel: #171b1c;
      --panel-2: #1e2324;
      --ink: #eef1f0;
      --muted: #9aa3a3;
      --muted-2: #7d8686;
      --accent: #5fc7cc;
      --accent-strong: #8adade;
      --accent-soft: rgba(95,199,204,0.13);
      --accent-soft-2: rgba(95,199,204,0.22);
      --warn: #e2ab44;
      --warn-soft: rgba(226,171,68,0.14);
      --ignore: #8b9394;
      --ignore-soft: rgba(139,147,148,0.12);
      --border: rgba(238,241,240,0.14);
      --border-2: rgba(238,241,240,0.24);
      --code-bg: #0a0d0e;
      --code-ink: #cfe3e0;
      --shadow: 0 1px 2px rgba(0,0,0,0.35), 0 12px 28px -14px rgba(0,0,0,0.6);
    }
  }
  * { box-sizing: border-box; }
  ::selection { background: var(--accent-soft-2); }
  html { scroll-behavior: smooth; }
  @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
  body {
    margin: 0;
    background: var(--bg);
    color: var(--ink);
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    -webkit-font-smoothing: antialiased;
  }
  .mono {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  }
  /* ---------- masthead + nav ---------- */
  .masthead {
    border-bottom: 1px solid var(--border);
    background: var(--panel);
  }
  .masthead-inner {
    max-width: 920px;
    margin: 0 auto;
    padding: 28px 20px 20px;
  }
  .kicker {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.68rem;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--accent);
    margin: 0 0 8px;
  }
  h1.title {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-weight: 700;
    font-size: clamp(1.4rem, 3vw, 1.9rem);
    letter-spacing: -0.01em;
    line-height: 1.25;
    text-wrap: balance;
    margin: 0 0 14px;
  }
  .meta-row {
    display: flex;
    flex-wrap: wrap;
    gap: 8px 10px;
    margin-bottom: 4px;
  }
  .badge {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.72rem;
    color: var(--muted);
    background: var(--panel-2);
    border: 1px solid var(--border);
    border-radius: 3px;
    padding: 3px 8px;
    white-space: nowrap;
  }
  .badge b { color: var(--ink); font-weight: 600; }
  .badge.warn { color: var(--warn); background: var(--warn-soft); border-color: transparent; }
  nav.jumpnav {
    position: sticky;
    top: 0;
    z-index: 20;
    background: var(--bg);
    border-bottom: 1px solid var(--border);
    backdrop-filter: blur(6px);
  }
  .jumpnav-inner {
    max-width: 920px;
    margin: 0 auto;
    padding: 10px 20px;
    display: flex;
    gap: 4px;
    overflow-x: auto;
  }
  .jumpnav a {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.74rem;
    color: var(--muted);
    text-decoration: none;
    padding: 5px 9px;
    border-radius: 3px;
    white-space: nowrap;
    flex: 0 0 auto;
  }
  .jumpnav a:hover, .jumpnav a:focus-visible {
    color: var(--ink);
    background: var(--panel-2);
  }
  main {
    max-width: 920px;
    margin: 0 auto;
    padding: 36px 20px 90px;
  }
  section { margin-top: 56px; }
  section:first-of-type { margin-top: 0; }
  .section-label {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.7rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted-2);
    margin: 0 0 6px;
  }
  h2 {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-weight: 700;
    font-size: 1.28rem;
    letter-spacing: -0.01em;
    margin: 0 0 8px;
    text-wrap: balance;
    scroll-margin-top: 56px;
  }
  h3 {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 1rem;
    font-weight: 700;
    margin: 0 0 10px;
  }
  p.lede {
    max-width: 68ch;
    color: var(--muted);
    line-height: 1.6;
    font-size: 0.96rem;
    margin: 0 0 22px;
  }
  p {
    max-width: 68ch;
    line-height: 1.65;
    font-size: 0.95rem;
    margin: 0 0 14px;
  }
  p:last-child { margin-bottom: 0; }
  p code, li code {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.87em;
    background: var(--panel-2);
    border-radius: 3px;
    padding: 1px 5px;
  }
  strong { font-weight: 650; }
  /* ---------- interactive try-it ---------- */
  .try-panel {
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 6px;
    box-shadow: var(--shadow);
    overflow: hidden;
  }
  .try-header {
    padding: 18px 22px;
    border-bottom: 1px solid var(--border);
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
  }
  .try-header h3 { margin: 0; }
  .scenario-toggle {
    display: flex;
    gap: 2px;
    background: var(--panel-2);
    border-radius: 20px;
    padding: 3px;
    border: 1px solid var(--border);
  }
  .scenario-toggle button {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.72rem;
    border: none;
    background: transparent;
    color: var(--muted);
    padding: 6px 12px;
    border-radius: 16px;
    cursor: pointer;
  }
  .scenario-toggle button.active {
    background: var(--accent);
    color: #ffffff;
  }
  :root[data-theme="dark"] .scenario-toggle button.active,
  @media (prefers-color-scheme: dark) {
    .scenario-toggle button.active { color: #06181a; }
  }
  .link-strip {
    padding: 20px 22px 8px;
  }
  .link-strip-label {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.68rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted-2);
    margin-bottom: 10px;
  }
  .chips {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    align-items: center;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.82rem;
  }
  .chips .plain {
    color: var(--muted-2);
  }
  .chip {
    border: 1px solid var(--border-2);
    background: var(--panel-2);
    color: var(--ink);
    border-radius: 4px;
    padding: 4px 8px;
    cursor: pointer;
    font-family: inherit;
    font-size: inherit;
    line-height: 1.3;
  }
  .chip .k { color: var(--muted); }
  .chip .v { color: var(--ink); font-weight: 600; }
  .chip.mapped { border-color: var(--accent); background: var(--accent-soft); }
  .chip.mapped .v { color: var(--accent-strong); }
  :root[data-theme="dark"] .chip.mapped .v { color: var(--accent-strong); }
  .chip.ignored { border-style: dashed; color: var(--ignore); background: var(--ignore-soft); }
  .chip.ignored .k, .chip.ignored .v { color: var(--ignore); }
  .chip.selected {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
  }
  .chip:hover { border-color: var(--accent); }
  .chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
  .extra-row {
    padding: 4px 22px 20px;
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
  }
  .extra-row-label {
    width: 100%;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.68rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted-2);
    margin-bottom: 8px;
  }
  .legend {
    display: flex;
    flex-wrap: wrap;
    gap: 14px;
    padding: 0 22px 18px;
    font-size: 0.78rem;
    color: var(--muted);
  }
  .legend span { display: inline-flex; align-items: center; gap: 6px; }
  .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
  .dot.mapped { background: var(--accent); }
  .dot.ignored { background: var(--ignore); }
  .detail {
    border-top: 1px solid var(--border);
    background: var(--panel-2);
    padding: 22px;
    transition: opacity 0.15s ease;
  }
  @media (prefers-reduced-motion: reduce) { .detail { transition: none; } }
  .detail-flow {
    display: flex;
    align-items: baseline;
    gap: 10px;
    flex-wrap: wrap;
    margin-bottom: 14px;
  }
  .detail-flow .from {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.85rem;
    color: var(--muted);
  }
  .detail-flow .arrow { color: var(--muted-2); }
  .detail-flow .to {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 1.05rem;
    font-weight: 700;
    color: var(--accent-strong);
  }
  .detail-flow .to.ignored-text { color: var(--ignore); font-weight: 600; font-size: 0.95rem; }
  .detail-meta {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin-bottom: 12px;
  }
  .pill {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.7rem;
    color: var(--muted);
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 20px;
    padding: 3px 10px;
  }
  .detail p.rule {
    font-size: 0.92rem;
    color: var(--ink);
    max-width: 62ch;
    margin: 0;
  }
  .detail-hint {
    color: var(--muted-2);
    font-size: 0.88rem;
  }
  /* ---------- chain flow ---------- */
  .flowchain {
    display: flex;
    flex-direction: column;
    gap: 0;
  }
  .flowstep {
    display: grid;
    grid-template-columns: 28px 1fr;
    gap: 14px;
  }
  .flowstep .rail {
    display: flex;
    flex-direction: column;
    align-items: center;
  }
  .flowstep .node {
    width: 10px; height: 10px; border-radius: 50%;
    background: var(--accent);
    flex: 0 0 auto;
    margin-top: 4px;
  }
  .flowstep .line {
    flex: 1;
    width: 1px;
    background: var(--border-2);
    margin-top: 4px;
  }
  .flowstep:last-child .line { display: none; }
  .flowstep .body { padding-bottom: 22px; }
  .flowstep .actor {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.72rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--accent);
    margin-bottom: 3px;
  }
  .flowstep .desc { font-size: 0.92rem; color: var(--ink); line-height: 1.5; }
  .flowstep .desc code { font-size: 0.85em; }
  /* ---------- rule list (numbered, real sequence) ---------- */
  ol.rules {
    margin: 0;
    padding: 0;
    list-style: none;
    counter-reset: rule;
    display: flex;
    flex-direction: column;
    gap: 16px;
  }
  ol.rules li {
    counter-increment: rule;
    position: relative;
    padding-left: 38px;
    max-width: 66ch;
  }
  ol.rules li::before {
    content: counter(rule);
    position: absolute;
    left: 0;
    top: 0;
    width: 26px;
    height: 26px;
    border-radius: 50%;
    background: var(--accent-soft);
    color: var(--accent-strong);
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.78rem;
    font-weight: 700;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  ol.rules li p { margin: 0; font-size: 0.94rem; line-height: 1.6; }
  ol.steps {
    margin: 0;
    padding: 0;
    list-style: none;
    counter-reset: step;
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  ol.steps li {
    counter-increment: step;
    position: relative;
    padding-left: 30px;
    max-width: 66ch;
    font-size: 0.94rem;
    line-height: 1.55;
  }
  ol.steps li::before {
    content: counter(step) ".";
    position: absolute;
    left: 0;
    top: 0;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-weight: 700;
    color: var(--accent);
    font-size: 0.85rem;
  }
  /* ---------- code block ---------- */
  .codeblock {
    background: var(--code-bg);
    color: var(--code-ink);
    border-radius: 6px;
    padding: 18px 20px;
    overflow-x: auto;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.82rem;
    line-height: 1.65;
  }
  .codeblock .c { color: #7d9b96; }
  .codeblock .k { color: #7fc7cc; }
  /* ---------- generic cards / grids ---------- */
  .card {
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 18px 20px;
    background: var(--panel);
  }
  .card + .card { margin-top: 14px; }
  .card h3 { margin-bottom: 8px; }
  ul.plain {
    margin: 0;
    padding: 0;
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  ul.plain li {
    position: relative;
    padding-left: 18px;
    max-width: 66ch;
    font-size: 0.94rem;
    line-height: 1.6;
  }
  ul.plain li::before {
    content: "";
    position: absolute;
    left: 0;
    top: 0.6em;
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background: var(--accent);
  }
  .pin-list {
    display: flex;
    flex-direction: column;
    gap: 14px;
  }
  .pin {
    border: 1px solid var(--border);
    border-left: 3px solid var(--warn);
    background: var(--warn-soft);
    border-radius: 0 6px 6px 0;
    padding: 14px 18px;
  }
  .pin .pin-tag {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.68rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--warn);
    margin-bottom: 6px;
    display: block;
  }
  .pin p { font-size: 0.92rem; margin: 0; max-width: 66ch; }
  .table-wrap { overflow-x: auto; }
  table.enum-table {
    border-collapse: collapse;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.82rem;
  }
  .env-row {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
  }
  /* ---------- touch-semantics session player ---------- */
  .session-btn {
    border: 1px solid var(--border-2);
    background: var(--panel-2);
    color: var(--ink);
    border-radius: 4px;
    padding: 8px 12px;
    cursor: pointer;
    font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    font-size: 0.86rem;
    text-align: left;
    line-height: 1.4;
  }
  .session-btn .n {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.68rem; color: var(--accent); display: block; margin-bottom: 2px;
  }
  .session-btn.active { border-color: var(--accent); background: var(--accent-soft); }
  .session-btn:hover { border-color: var(--accent); }
  .state-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
  .state-table th, .state-table td { text-align: left; padding: 8px 12px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
  .state-table th { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 0.68rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); }
  .state-table td.field { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-weight: 600; white-space: nowrap; }
  .state-table td.val { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; }
  .status-pill {
    font-size: 0.7rem; font-weight: 600; padding: 2px 8px; border-radius: 10px; white-space: nowrap;
  }
  .status-pill.changed { color: var(--accent-strong); background: var(--accent-soft); }
  .status-pill.unchanged { color: var(--muted); background: var(--panel-2); }
  .status-pill.carried { color: var(--warn); background: var(--warn-soft); }
  footer.provenance {
    max-width: 920px;
    margin: 0 auto;
    padding: 30px 20px 60px;
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
    font-size: 0.72rem;
    line-height: 1.6;
    color: var(--muted-2);
    border-top: 1px solid var(--border);
  }
  footer.provenance code { color: var(--muted); }
</style>
<header class="masthead">
  <div class="masthead-inner">
    <p class="kicker">Developer guide · v1.2 · Interactive</p>
    <h1 class="title">AirBridge → Mixpanel Instrumentation Mapping</h1>
    <div class="meta-row">
      <span class="badge">Owner <b>Sourabh Mishra</b> (MMRP)</span>
      <span class="badge">Route <b>Sourabh → Kapil → Prajwalit</b></span>
      <span class="badge">Audience <b>iOS + Android</b></span>
    </div>
    <div class="meta-row">
      <span class="badge">Sink: <b>Mixpanel only</b></span>
      <span class="badge">Prod <b>4017469</b></span>
      <span class="badge">Staging <b>4017466</b></span>
      <span class="badge">Dev <b>4002103</b></span>
      <span class="badge warn">Prod is behind — see §Naming</span>
      <span class="badge warn">v1.2 adds marketing attribution (§3b) — see changelog at bottom</span>
    </div>
  </div>
</header>
<nav class="jumpnav" aria-label="Section navigation">
  <div class="jumpnav-inner">
    <a href="#tryit">Try it</a>
    <a href="#touchsemantics">Touch semantics</a>
    <a href="#chain">The chain</a>
    <a href="#timing">Timing rules</a>
    <a href="#code">Code</a>
    <a href="#serverjoin">Server join</a>
    <a href="#naming">Naming</a>
    <a href="#dpdp">DPDP</a>
    <a href="#verify">Verify</a>
    <a href="#pins">Open pins</a>
    <a href="#whatchanged">Changelog</a>
  </div>
</nav>
<main>
  <section id="tryit">
    <p class="section-label">Interactive — §3</p>
    <h2>Click any link parameter to see where it lands</h2>
    <p class="lede">This is a real example AirBridge link. Every parameter maps to something in Mixpanel — or explicitly doesn't. Click one to see the rule.</p>
    <div class="try-panel">
      <div class="try-header">
        <h3>Example link</h3>
        <div style="display:flex; gap:10px; flex-wrap:wrap;">
          <div class="scenario-toggle" role="group" aria-label="Link type">
            <button type="button" data-scenario="affiliate" class="active">Affiliate link</button>
            <button type="button" data-scenario="marketing">Marketing link</button>
          </div>
          <div class="scenario-toggle" role="group" aria-label="Funding">
            <button type="button" data-funding="organic" class="active">Organic</button>
            <button type="button" data-funding="sponsorship_funded">Sponsorship-funded</button>
          </div>
        </div>
      </div>
      <div class="link-strip">
        <div class="link-strip-label">go.mixxi.ai/m/… — query parameters</div>
        <div class="chips" id="chip-row"></div>
      </div>
      <div class="extra-row">
        <div class="extra-row-label">Also part of the mapping — not URL query params</div>
        <div class="chips" id="chip-row-extra"></div>
      </div>
      <div class="legend">
        <span><span class="dot mapped"></span> Maps to Mixpanel</span>
        <span><span class="dot ignored"></span> Not forwarded — debug only</span>
      </div>
      <div class="detail" id="detail-panel"></div>
      <div style="padding: 0 22px 22px;">
        <div class="card" style="background: var(--warn-soft); border-color: transparent;">
          <h3 style="color: var(--warn); margin-bottom:6px;">Property-naming rule — do not drift</h3>
          <p style="margin:0; font-size:0.88rem;">All optional link-derived event props use the <strong>exact <code>mx_*</code> names</strong> from the <code>param_map</code> in <code>mixxi_attribution_enums.json</code>: <code>mx_channel</code>, <code>mx_placement</code>, <code>mx_phase</code>, <code>mx_episode</code>, <code>mx_recipe</code>, <code>mx_asset</code>, <code>mx_cta</code>, <code>mx_team</code>, <code>mx_funding</code>, <code>mx_ad_platform</code>, <code>mx_persona</code>, <code>mx_layer</code>, <code>mx_aff</code>. MMRP's Channels tab and readout query these exact names — a prop written under any other name will exist in Mixpanel but be invisible to every MMRP surface. The three <strong>profile</strong> keys keep their own names: <code>acquisition_source</code>, <code>marketing_affiliate_id</code>, <code>airbridge_id</code>.</p>
        </div>
      </div>
    </div>
  </section>
  <section id="touchsemantics">
    <p class="section-label">§3b — new in v1.2, "read carefully"</p>
    <h2>Touch semantics: profile vs. super props</h2>
    <p class="lede">The rule that's easy to get backwards: profile fields never change after first touch, but the <code>mx_*</code> super props update on every new tagged open — and organic opens don't clear them. Step through three sessions for the same user to see it happen.</p>
    <div class="try-panel">
      <div class="try-header" style="justify-content:flex-start; gap:16px;">
        <h3 style="margin:0;">Play through: one user, three sessions</h3>
      </div>
      <div class="link-strip" style="padding-bottom:16px;">
        <div class="chips" id="session-buttons" style="gap:8px;"></div>
      </div>
      <div class="detail" id="session-detail" style="padding-top:8px;"></div>
    </div>
  </section>
  <section id="chain">
    <p class="section-label">§1</p>
    <h2>The chain — what happens on a click</h2>
    <p class="lede">The app owns everything inside the device. The server-side join is MMRP's — but it only works if the app sets the join keys, which is why this is a developer spec.</p>
    <div class="flowchain">
      <div class="flowstep">
        <div class="rail"><div class="node"></div><div class="line"></div></div>
        <div class="body">
          <div class="actor">User</div>
          <div class="desc">Taps a <code>go.mixxi.ai</code> / <code>abr.ge</code> link (carries <code>channel</code>, <code>campaign</code>, sub-params).</div>
        </div>
      </div>
      <div class="flowstep">
        <div class="rail"><div class="node"></div><div class="line"></div></div>
        <div class="body">
          <div class="actor">AirBridge → OS</div>
          <div class="desc">Redirect + store fallback, then the OS deep-link resolver opens the app (or triggers install → first launch).</div>
        </div>
      </div>
      <div class="flowstep">
        <div class="rail"><div class="node"></div><div class="line"></div></div>
        <div class="body">
          <div class="actor">App — consent gate</div>
          <div class="desc">DPDP consent gate. <strong>Nothing</strong> fires until affirmative consent.</div>
        </div>
      </div>
      <div class="flowstep">
        <div class="rail"><div class="node"></div><div class="line"></div></div>
        <div class="body">
          <div class="actor">App — SDK init order</div>
          <div class="desc">Mixpanel initializes <strong>first</strong> (<code>distinct_id</code> ready), then AirBridge SDK initializes.</div>
        </div>
      </div>
      <div class="flowstep">
        <div class="rail"><div class="node"></div><div class="line"></div></div>
        <div class="body">
          <div class="actor">AirBridge → App</div>
          <div class="desc">Deferred deep-link params on first launch, or immediate params if already installed.</div>
        </div>
      </div>
      <div class="flowstep">
        <div class="rail"><div class="node"></div><div class="line"></div></div>
        <div class="body">
          <div class="actor">App → Mixpanel</div>
          <div class="desc">Sets <code>acquisition_source</code> (<code>$set_once</code>), <code>marketing_affiliate_id</code>, <code>airbridge_id</code>.</div>
        </div>
      </div>
      <div class="flowstep">
        <div class="rail"><div class="node"></div><div class="line"></div></div>
        <div class="body">
          <div class="actor">App → Mixpanel</div>
          <div class="desc">Tracks <code>signup_complete</code> / <code>live_watch_anchor</code> with join props attached.</div>
        </div>
      </div>
      <div class="flowstep">
        <div class="rail"><div class="node"></div><div class="line"></div></div>
        <div class="body">
          <div class="actor">AirBridge → MMRP</div>
          <div class="desc">Server postback carries deterministic attribution data.</div>
        </div>
      </div>
      <div class="flowstep">
        <div class="rail"><div class="node"></div></div>
        <div class="body">
          <div class="actor">MMRP</div>
          <div class="desc">Joins the postback to Mixpanel via <code>airbridge_id</code> / <code>marketing_affiliate_id</code>.</div>
        </div>
      </div>
    </div>
  </section>
  <section id="timing">
    <p class="section-label">§2 — read this first, most bugs live here</p>
    <h2>Identity &amp; timing model</h2>
    <p class="lede">Four rules, in this order. Breaking the order causes silent attribution loss — no error, just a missing join.</p>
    <ol class="rules">
      <li><p><strong>Consent gate is absolute (DPDP).</strong> No SDK initializes, no token/cookie/storage is written, and no event fires before affirmative consent. Deny-by-default.</p></li>
      <li><p><strong>Mixpanel initializes before any attribution event.</strong> If an attribution event fires before Mixpanel has a <code>distinct_id</code>, the event is lost and never attributed. Order: consent → Mixpanel init → AirBridge init → read link → set props.</p></li>
      <li><p><strong>Read the deferred deep link on first launch.</strong> For a new install, the affiliate code arrives via AirBridge's <em>deferred</em> deep-link callback — not the immediate handler. Handling only immediate deep links makes every affiliate install look organic, and no affiliate gets paid. Handle both.</p></li>
      <li><p><strong><code>acquisition_source</code> is first-touch immutable.</strong> Write it with <code>$set_once</code> — never overwrite. Null resolves to <code>unknown</code>. <code>marketing_affiliate_id</code> and <code>airbridge_id</code> are set on first attributed touch and used as deterministic join keys — no fingerprinting.</p></li>
    </ol>
  </section>
  <section id="code">
    <p class="section-label">§4</p>
    <h2>Step-by-step (app side)</h2>
    <p class="lede">Illustrative pseudo-code — use the real AirBridge/Mixpanel SDK method names per platform. The <strong>order</strong> and the <strong>property names</strong> are what matter.</p>
    <div class="codeblock"><pre style="margin:0; white-space: pre-wrap;"><span class="c">// 1. Consent gate — DPDP deny-by-default. Nothing below runs until true.</span>
if (!userHasConsented()) return;
<span class="c">// 2. Mixpanel FIRST — distinct_id must exist before any attribution event.</span>
mixpanel.init(<span class="k">MIXPANEL_TOKEN</span>);            <span class="c">// token per environment</span>
mixpanel.identify(stableDistinctId());
<span class="c">// 3. AirBridge SDK.</span>
airbridge.init(<span class="k">AIRBRIDGE_APP_TOKEN</span>);      <span class="c">// token per environment — never staging token in prod build</span>
<span class="c">// 4. Deferred deep link (NEW installs) + immediate (existing installs).</span>
airbridge.onDeferredDeeplink(params -&gt; applyAttribution(params));
airbridge.onDeeplink(params -&gt; applyAttribution(params));
function applyAttribution(params) {
    <span class="c">// 5a. PROFILE — first-touch, immutable (never overwrite).</span>
    mixpanel.people.setOnce({ acquisition_source: rollup(params.channel) });  <span class="c">// see §3 Pin #0 — raw channel until rollup ships</span>
    mixpanel.people.set({ airbridge_id: airbridge.getAirbridgeId() });
    <span class="c">// affiliate links only — code carried in `campaign`, matches deep-link path</span>
    if (isAffiliate(params)) {
        mixpanel.people.set({ marketing_affiliate_id: params.campaign });     <span class="c">// first attributed touch</span>
    }
    <span class="c">// 5b. MARKETING CONTEXT — latest-touch super props (§3b). OVERWRITE on every</span>
    <span class="c">// attributed open; ride automatically on all subsequent events.</span>
    mixpanel.register({
        acquisition_source: rollup(params.channel),
        mx_channel:   params.channel,
        mx_phase:     splitCampaign(params.campaign).phase,      <span class="c">// marketing links: phase__episode</span>
        mx_episode:   splitCampaign(params.campaign).episode,
        mx_recipe:    splitCreative(params.ad_creative).recipe,
        mx_asset:     splitCreative(params.ad_creative).asset,
        mx_placement: params.content,
        mx_funding:   params.sub_param_funding,                  <span class="c">// organic | sponsorship_funded</span>
        mx_ad_platform: params.sub_param_ad_platform,            <span class="c">// paid only; omit when absent/none</span>
        mx_persona:   params.sub_param_persona,
        mx_layer:     params.sub_param_layer,
        mx_team:      params.sub_param_team,
        mx_cta:       params.sub_param_cta,
        ...(isAffiliate(params) && { marketing_affiliate_id: params.campaign, mx_aff: params.campaign })
    });  <span class="c">// omit any absent param entirely — no empty strings</span>
}
<span class="c">// 6. Emit funnel events with join props attached (super props ride automatically).</span>
mixpanel.track(<span class="k">"signup_complete"</span>, { new_otp_unique_account: &lt;bool&gt; });         <span class="c">// NOT account_created</span>
mixpanel.track(<span class="k">"live_watch_anchor"</span>, { episode_id, episode_number, watch_duration });</pre></div>
    <div class="card" style="margin-top:16px;">
      <h3>Platform notes</h3>
      <ul class="plain">
        <li><strong>iOS:</strong> register deep-link handling in the SceneDelegate/AppDelegate path AirBridge documents; ensure ATT/consent timing does not fire attribution before Mixpanel init.</li>
        <li><strong>Android:</strong> intent filters + <code>autoVerify</code>; ensure the deferred-link callback is registered before the first activity that could emit an event.</li>
        <li><strong>Both:</strong> enum values are lowercase; Mixpanel will not dedupe <code>WhatsApp</code> vs <code>whatsapp</code>.</li>
      </ul>
    </div>
  </section>
  <section id="serverjoin">
    <p class="section-label">§5</p>
    <h2>Server-side join — why the app props matter</h2>
    <p>The AirBridge server postback hits MMRP at <code>/api/ingestion/airbridge/*</code>. MMRP joins that postback to the Mixpanel funnel using <strong><code>airbridge_id</code></strong> (and <code>marketing_affiliate_id</code> for affiliate credit). The join is deterministic-only — if the app didn't set these keys, the postback and the funnel can't be joined and the install looks organic.</p>
    <p>Affiliate credit follows a <strong>no-clawback</strong> ledger: money is only released on met conditions, never reversed — so a missing join key means an affiliate is silently underpaid. That's why §2/§3 are strict.</p>
  </section>
  <section id="naming">
    <p class="section-label">§6 — must not get wrong</p>
    <h2>Naming &amp; parity</h2>
    <ul class="plain">
      <li>The signup event is <strong><code>signup_complete</code></strong>, not <code>account_created</code>. <code>account_created</code> exists in no Mixpanel environment; any code emitting it produces silent zeros. MAP payout logic keys off <code>signup_complete</code>.</li>
      <li><strong>iOS === Android:</strong> identical event names, identical property names, identical lowercase enum values.</li>
      <li><strong>Production is currently behind</strong> — <code>marketing_affiliate_id</code> is on zero events in Prod (present in Staging/Dev). This guide is exactly what closes that gap; verify it lands in all three environments.</li>
    </ul>
  </section>
  <section id="dpdp">
    <p class="section-label">§7 — non-negotiable</p>
    <h2>DPDP hard constraints</h2>
    <ul class="plain">
      <li>No phone numbers, no raw PII in any Mixpanel property. Linkage is pseudonymous (<code>airbridge_id</code>).</li>
      <li>Nothing fires before affirmative consent (deny-by-default, granular, all unchecked).</li>
      <li><code>new_otp_unique_account</code> is a <strong>boolean only</strong> — never the phone number or a hash of it.</li>
      <li>Permitted property content: IDs, enums, booleans, <code>episode_id</code>, <code>acquisition_source</code>. Nothing else.</li>
    </ul>
  </section>
  <section id="verify">
    <p class="section-label">§8</p>
    <h2>Verify — one decisive end-to-end test</h2>
    <p class="lede">Do this on a clean device (no prior install), per platform, per environment.</p>
    <ol class="steps">
      <li>Cut a test link (<code>go.mixxi.ai</code> or <code>abr.ge</code>) with a known <code>campaign</code> code, e.g. <code>RISING-E0-TESTONLY</code>, deep link <code>mixxi://mixxi/m/RISING-E0-TESTONLY</code>.</li>
      <li>Tap it → install → open → complete consent → sign up.</li>
      <li>In Mixpanel Live View (correct environment), confirm: <code>signup_complete</code> fired; <code>marketing_affiliate_id</code> == <code>RISING-E0-TESTONLY</code> exactly; <code>acquisition_source</code> set (not null/unknown for a known channel); <code>airbridge_id</code> present.</li>
      <li>Confirm the AirBridge postback for the same install carries the same code, and MMRP resolves the join.</li>
    </ol>
    <div class="card" style="margin-top:18px;">
      <h3>Success criteria</h3>
      <p>The same affiliate code is visible on both sides (AirBridge postback <em>and</em> Mixpanel <code>marketing_affiliate_id</code>) and MMRP joins them. If the code is missing on the Mixpanel side, the break is app-side (deferred link not read, or props set before Mixpanel init). If present in Mixpanel but not joined, the break is the postback field mapping (see Open Pins).</p>
    </div>
    <h3 style="margin-top:26px;">Second decisive test — a marketing link (new in v1.2, do this too)</h3>
    <ol class="steps">
      <li>Cut a <em>marketing</em> test link: channel <code>instagram</code>, campaign <code>phase1_tease__ep01_discovery</code>, creative <code>trend__testasset_01</code>, <code>sub_param_funding=organic</code>.</li>
      <li>Clean device → tap → install → consent → sign up → then trigger one more event (e.g. open an episode).</li>
      <li>In Mixpanel Live View confirm: <code>signup_complete</code> AND the later event <strong>both</strong> carry <code>mx_channel=instagram</code>, <code>mx_phase=phase1_tease</code>, <code>mx_episode=ep01_discovery</code>, <code>mx_recipe=trend</code>, <code>mx_asset=testasset_01</code>, <code>mx_funding=organic</code> — and <strong>no</strong> <code>marketing_affiliate_id</code> (it's not an affiliate link).</li>
      <li>Latest-touch check: on the same device, tap a <em>second</em> marketing link with a different channel (e.g. <code>whatsapp</code>) → open → trigger an event → confirm the event's <code>mx_channel</code> is now <code>whatsapp</code> while the profile's <code>acquisition_source</code> is unchanged from the first link.</li>
    </ol>
    <div class="card" style="margin-top:18px;">
      <h3>Success criteria</h3>
      <p>Funnel events carry the full <code>mx_*</code> context matching the link that drove the session; the profile stays first-touch. If events show no <code>mx_*</code> props, the super-prop registration isn't firing. If they show the <em>old</em> campaign after a new tagged open, the overwrite semantics are wrong (§3b — see the Touch semantics walkthrough above).</p>
    </div>
  </section>
  <section id="pins">
    <p class="section-label">§9 — do NOT hardcode against these until confirmed</p>
    <h2>Open pins</h2>
    <div class="pin-list">
      <div class="pin">
        <span class="pin-tag">New in v1.1 — Owner: Sourabh/MMRP, ships as enums v1.4.0</span>
        <p><strong>Pin #0 — <code>channel → acquisition_source</code> rollup enum.</strong> Does not yet exist in the committed enums file. Until it ships: write the raw <code>channel</code> value into <code>acquisition_source</code> (<code>$set_once</code>, null→<code>unknown</code>) rather than guessing a coarse mapping — a wrong value written <code>$set_once</code> is permanent.</p>
      </div>
      <div class="pin">
        <span class="pin-tag">Pending — Punit (template owner)</span>
        <p><strong>Affiliate-code postback field — <code>partner</code> vs <code>sub_id</code>.</strong> Which AirBridge field the tracking template forwards to the MMRP receiver is console-defined and unconfirmed. Build against <code>campaign</code> as the in-app source of <code>marketing_affiliate_id</code> (that's stable); treat the postback field name as unconfirmed. Recommendation on record: forward as a dedicated <code>sub_id</code>, not <code>partner</code>.</p>
      </div>
      <div class="pin">
        <span class="pin-tag">In progress — parallel workstream</span>
        <p><strong>Branded domain <code>go.mixxi.ai</code>.</strong> Being stood up in parallel (CNAME pending DNS). Does not change the affiliate-code parameter contract. Deferred-deep-link attribution works regardless of domain; Universal Link / App Link direct-open on the new domain is a separate, freeze-gated entitlement change.</p>
      </div>
      <div class="pin">
        <span class="pin-tag">Updated in v1.1 — ownership: Unravel</span>
        <p><strong><code>vote_cast</code> source</strong> — client Mixpanel vs. server-side emit from the Kafka <code>rising.vote.validated</code> stream. The Kafka topic is now Unravel's (transferred with the Void gap). Preferred resolution on record: <strong>Unravel's backend consumes the validated stream and emits <code>vote_cast</code> server-side into Mixpanel</strong> (keeps votes trusted for payout release and keeps MMRP Mixpanel-only). Do not build <code>vote_cast</code> client-side until settled.</p>
      </div>
      <div class="pin">
        <span class="pin-tag">New in v1.1 — MMRP action item</span>
        <p><strong>Pin #4 — <code>signup_complete</code> as the payout event.</strong> This guide standardises on <code>signup_complete</code> (the only signup event that exists in any environment). MAP payout logic historically referenced <code>account_created</code>; re-pointing it to <code>signup_complete</code> is an MMRP action item, tracked separately. App-side: emit <code>signup_complete</code>, never <code>account_created</code>.</p>
      </div>
    </div>
  </section>
  <section id="whatchanged">
    <p class="section-label">Chief PM review (v1.1) + marketing attribution (v1.2)</p>
    <h2>Changelog — what changed</h2>
    <p class="lede">Two revisions today. v1.1 corrected claims that turned out to be wrong against the committed enums file. v1.2 is additive: a whole new attribution contract for non-affiliate marketing links that didn't exist before.</p>
    <h3 style="margin-top:4px;">v1.1 — corrections against the committed enums file</h3>
    <div class="pin-list">
      <div class="pin">
        <p><strong>Fixed a fabricated claim.</strong> v1.0 said the <code>acquisition_source</code> rollup "lives in <code>mixxi_attribution_enums.json</code>." It doesn't — no such group exists in the committed file. Now an explicit open pin with a safe interim (write the raw <code>channel</code> value).</p>
      </div>
      <div class="pin">
        <p><strong>Fixed <code>funding</code> values.</strong> <code>organic</code>/<code>paid</code> → <strong><code>organic</code>/<code>sponsorship_funded</code></strong> — the only two values that exist in the enum.</p>
      </div>
      <div class="pin">
        <p><strong>Fixed event-prop names to the committed <code>param_map</code>.</strong> <code>persona</code>/<code>funding_type</code>/<code>distribution_layer</code>/<code>owner_team</code>/<code>cta</code> → <strong><code>mx_persona</code>/<code>mx_funding</code>/<code>mx_layer</code>/<code>mx_team</code>/<code>mx_cta</code></strong>. MMRP's dashboards query the <code>mx_*</code> names — the v1.0 names would land in Mixpanel but be invisible to every MMRP surface.</p>
      </div>
      <div class="pin">
        <p><strong>Added the missing <code>sub_param_ad_platform</code> → <code>mx_ad_platform</code> row</strong> (paid links only — present when <code>funding=sponsorship_funded</code>, omitted for organic).</p>
      </div>
      <div class="pin">
        <p><strong>Updated the <code>vote_cast</code> pin</strong> with Unravel ownership and the preferred server-side-emit resolution; added a new pin on the <code>signup_complete</code> payout re-point.</p>
      </div>
    </div>
    <h3 style="margin-top:20px;">v1.2 — marketing attribution added (new, not a correction)</h3>
    <div class="pin-list">
      <div class="pin">
        <p><strong>A third load-bearing output.</strong> Alongside <code>acquisition_source</code> and <code>marketing_affiliate_id</code>, every session must now carry a full set of <code>mx_*</code> campaign-context super props — <code>mx_channel</code>, <code>mx_phase</code>, <code>mx_episode</code>, <code>mx_recipe</code>, <code>mx_asset</code>, <code>mx_placement</code>, <code>mx_aff</code>. This is what feeds the Channels tab, the Saturday readout, and per-creative analysis. Previously about half the <code>param_map</code> was unmapped to anything.</p>
      </div>
      <div class="pin">
        <p><strong>Touch semantics — the new hard part.</strong> Profile fields (<code>acquisition_source</code>, <code>marketing_affiliate_id</code>) stay first-touch immutable, but the <code>mx_*</code> super props are <strong>latest-touch</strong> — overwritten on every new tagged open, left alone on organic opens. Getting this backwards (e.g. <code>$set_once</code>-ing the super props, or clearing them on organic opens) silently breaks channel/creative attribution. See the interactive walkthrough above.</p>
      </div>
      <div class="pin">
        <p><strong>A second verify test</strong> (§8) specifically for marketing links, including a "tap a second link, different channel" step to confirm the overwrite behavior actually happens.</p>
      </div>
    </div>
  </section>
</main>
<footer class="provenance">
  Source: AirBridge → Mixpanel Mapping Guide v1.2 · 22 Jul 2026 · Ingestion &amp; Experimentation, MMRP · route Sourabh → Kapil → Prajwalit · single sink Mixpanel 4017469 · Confidential — Celerity Studios.
  Full source document stored in the Celerity org brain at <code>meetings/inbox/2026-07-22-airbridge-mixpanel-mapping-guide.md</code> (<code>status: proposed</code>), updated in place v1.0 → v1.1 → v1.2.
</footer>
<script>
(function () {
  var MAPPED = {
    channel: {
      example: "whatsapp",
      target: "acquisition_source",
      via: "enum rollup — PROPOSED, pending commit",
      setWhere: "profile · $set_once",
      rule: "⚠️ v1.1 correction: this rollup does NOT yet exist in lib/attribution/mixxi_attribution_enums.json (v1.3.1 committed) — v1.0 wrongly claimed it did. It's Pin #0: don't hardcode a guessed channel→acquisition_source mapping. Safe interim: write the raw channel value into acquisition_source verbatim ($set_once, null→unknown) — it can be rolled up later server-side, but a wrong coarse value written $set_once is permanent."
    },
    sub_param_persona: {
      example: "style_aspirant",
      target: "mx_persona",
      setWhere: "event prop · optional",
      rule: "Lowercase enum. v1.1: renamed from persona to mx_persona to match the committed param_map — MMRP's dashboards only query the mx_* name."
    },
    sub_param_funding: {
      example: "organic",
      target: "mx_funding",
      setWhere: "event prop · optional",
      rule: "v1.1 correction: only two values exist — organic | sponsorship_funded. There is no \"paid\" value in the enum (v1.0 said organic/paid). Also renamed funding_type → mx_funding."
    },
    sub_param_ad_platform: {
      example: "meta",
      target: "mx_ad_platform",
      setWhere: "event prop · paid links only",
      rule: "New in v1.1 — was missing from v1.0 entirely. Present only when funding=sponsorship_funded; omit for organic links. Toggle “Funding” above to see it appear/disappear."
    },
    ad_creative: {
      example: "trend__testasset_01",
      target: "mx_recipe + mx_asset",
      setWhere: "super prop · optional",
      rule: "New in v1.2 (§3b). Split on the first \"__\": part 1 → mx_recipe (\"trend\"), part 2 → mx_asset (\"testasset_01\"). Registered as a super prop — rides on every subsequent event in the session, not just the attributing one."
    },
    content: {
      example: "reel",
      target: "mx_placement",
      setWhere: "super prop · optional",
      rule: "New in v1.2 (§3b). The surface/unit the ad ran in — carried straight through as mx_placement."
    },
    sub_param_layer: {
      example: "deep_link",
      target: "mx_layer",
      setWhere: "event prop · optional",
      rule: "Three-layer model: feed / deep_link / mobile_web. v1.1: renamed from distribution_layer to mx_layer."
    },
    sub_param_team: {
      example: "delhi_gtm",
      target: "mx_team",
      setWhere: "event prop · optional",
      rule: "Lowercase enum identifying the owning GTM team. v1.1: renamed from owner_team to mx_team."
    },
    sub_param_cta: {
      example: "app_install",
      target: "mx_cta",
      setWhere: "event prop · optional",
      rule: "Lowercase enum describing the call-to-action that drove the tap. v1.1: renamed from cta to mx_cta."
    },
    og_tag_id: { example: "og_a91f", ignored: true },
    routing_short_id: { example: "rt_3382", ignored: true },
    tracking_template_id: { example: "tt_772", ignored: true },
    ad_type: { example: "install", ignored: true }
  };
  var SCENARIOS = {
    affiliate: {
      campaign: {
        example: "RISING-E0-A7K2P9",
        target: "marketing_affiliate_id + mx_aff",
        setWhere: "profile (first-touch) + super prop (latest-touch)",
        rule: "The affiliate code lives in campaign (per MAP runbook) and must equal the deep-link path code exactly — a mismatch silently misattributes. v1.2: it's also copied onto the mx_aff super prop, so it rides on session events the same way marketing context does — the profile copy is still first-touch immutable."
      },
      deeplink: {
        label: "deep link path",
        example: "mixxi://mixxi/m/RISING-E0-A7K2P9",
        target: "confirms affiliate code",
        ignoredStyle: false,
        setWhere: "—",
        rule: "Not a Mixpanel target itself — this path code must match the campaign parameter exactly. A mismatch silently misattributes the install."
      }
    },
    marketing: {
      campaign: {
        example: "phase1_tease__ep01_discovery",
        target: "mx_phase + mx_episode",
        setWhere: "super prop · latest-touch",
        rule: "v1.2 (§3b): marketing campaigns split on the first \"__\" — part 1 → mx_phase (\"phase1_tease\"), part 2 → mx_episode (\"ep01_discovery\"). Still not an affiliate id — do NOT write marketing_affiliate_id for these. Overwritten on every new tagged open (latest-touch), unlike the first-touch profile fields.",
        neutral: true
      },
      deeplink: {
        label: "deep link path",
        example: "mixxi://mixxi/m/phase1-tease",
        target: "no affiliate check needed",
        ignoredStyle: false,
        setWhere: "—",
        rule: "Marketing links have no affiliate code to reconcile — this check only applies to affiliate links.",
        neutral: true
      }
    }
  };
  var EXTRA = {
    deeplink: { label: "deep link path" },
    airbridge_id: {
      label: "AirBridge attribution result",
      example: "(SDK-provided)",
      target: "airbridge_id",
      setWhere: "profile",
      rule: "The pseudonymous key MMRP uses to join the server postback. Never a phone number. No fingerprinting — deterministic-first, for compliance."
    }
  };
  var state = { scenario: "affiliate", funding: "organic", selected: "channel" };
  var chipRow = document.getElementById("chip-row");
  var chipRowExtra = document.getElementById("chip-row-extra");
  var detailPanel = document.getElementById("detail-panel");
  function paramData(key) {
    if (key === "campaign") return SCENARIOS[state.scenario].campaign;
    if (key === "deeplink") return SCENARIOS[state.scenario].deeplink;
    if (key === "airbridge_id") return EXTRA.airbridge_id;
    if (key === "sub_param_ad_platform") {
      var base = MAPPED.sub_param_ad_platform;
      if (state.funding === "organic") {
        return {
          example: "(omitted)",
          ignored: true,
          toLabel: "Omitted — organic link",
          rule: "Omitted for organic links — mx_ad_platform is only present when funding=sponsorship_funded. Toggle “Funding” to sponsorship-funded to see it appear."
        };
      }
      return base;
    }
    if (key === "sub_param_funding") {
      var f = MAPPED.sub_param_funding;
      return Object.assign({}, f, { example: state.funding });
    }
    return MAPPED[key];
  }
  function makeChip(key, opts) {
    var d = paramData(key);
    var btn = document.createElement("button");
    btn.type = "button";
    btn.className = "chip" + (d.ignored ? " ignored" : (d.neutral ? "" : " mapped"));
    btn.setAttribute("data-key", key);
    var label = (opts && opts.label) ? opts.label : key;
    btn.innerHTML = '<span class="k">' + label + '=</span><span class="v">' + d.example + '</span>';
    btn.addEventListener("click", function () { select(key); });
    return btn;
  }
  function renderChips() {
    chipRow.innerHTML = "";
    chipRow.appendChild(makeChip("channel"));
    chipRow.appendChild(makeChip("campaign"));
    ["sub_param_persona", "sub_param_funding", "sub_param_ad_platform", "sub_param_layer", "sub_param_team", "sub_param_cta", "ad_creative", "content"].forEach(function (k) {
      chipRow.appendChild(makeChip(k));
    });
    chipRow.appendChild(makeChip("og_tag_id"));
    chipRow.appendChild(makeChip("routing_short_id"));
    chipRow.appendChild(makeChip("tracking_template_id"));
    chipRow.appendChild(makeChip("ad_type"));
    chipRowExtra.innerHTML = "";
    chipRowExtra.appendChild(makeChip("deeplink", { label: "path" }));
    chipRowExtra.appendChild(makeChip("airbridge_id", { label: "sdk" }));
    highlightSelected();
  }
  function highlightSelected() {
    document.querySelectorAll(".chip").forEach(function (c) {
      c.classList.toggle("selected", c.getAttribute("data-key") === state.selected);
    });
  }
  function renderDetail() {
    var key = state.selected;
    var d = paramData(key);
    if (!d) { detailPanel.innerHTML = '<p class="detail-hint">Click a parameter above to see its mapping.</p>'; return; }
    if (d.ignored) {
      detailPanel.innerHTML =
        '<div class="detail-flow"><span class="from mono">' + key + '</span><span class="arrow">→</span><span class="to ignored-text">' + (d.toLabel || "Not forwarded to Mixpanel") + '</span></div>' +
        '<p class="rule">' + (d.rule || "AirBridge-generated, debug-only. Ignore in instrumentation — do not read or forward this field.") + '</p>';
      return;
    }
    var metaHtml = '<div class="detail-meta">';
    if (d.setWhere) metaHtml += '<span class="pill">' + d.setWhere + '</span>';
    metaHtml += '</div>';
    detailPanel.innerHTML =
      '<div class="detail-flow"><span class="from mono">' + (d.label || key) + '</span><span class="arrow">→</span><span class="to">' + d.target + '</span></div>' +
      metaHtml +
      '<p class="rule">' + d.rule + '</p>';
  }
  function select(key) {
    state.selected = key;
    highlightSelected();
    renderDetail();
  }
  document.querySelectorAll(".scenario-toggle button[data-scenario]").forEach(function (btn) {
    btn.addEventListener("click", function () {
      document.querySelectorAll(".scenario-toggle button[data-scenario]").forEach(function (b) { b.classList.remove("active"); });
      btn.classList.add("active");
      state.scenario = btn.getAttribute("data-scenario");
      renderChips();
      renderDetail();
    });
  });
  document.querySelectorAll(".scenario-toggle button[data-funding]").forEach(function (btn) {
    btn.addEventListener("click", function () {
      document.querySelectorAll(".scenario-toggle button[data-funding]").forEach(function (b) { b.classList.remove("active"); });
      btn.classList.add("active");
      state.funding = btn.getAttribute("data-funding");
      renderChips();
      renderDetail();
    });
  });
  renderChips();
  renderDetail();
  // ---------- touch-semantics session player ----------
:root{--bg:#f3f6f5;--panel:#fff;--ink:#162022;--muted:#5f6b6d;--line:#d9e2df;--accent:#167278;--soft:#dff3f0;--warn:#9b6300;--warnbg:#fff3d6;--code:#172325}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,-apple-system,sans-serif}main{max-width:1080px;margin:auto;padding:38px 22px 80px}header{background:linear-gradient(135deg,#123f43,#1d7778);color:white;border-radius:22px;padding:38px}h1{font-size:clamp(32px,5vw,58px);line-height:1.03;letter-spacing:-.045em;margin:12px 0}h2{font-size:28px;line-height:1.15;margin:8px 0 18px}.eyebrow,.label{font-size:12px;letter-spacing:.12em;text-transform:uppercase;font-weight:750;opacity:.8}.hero{max-width:850px;font-size:17px;color:#e2f1ef}.badges{display:flex;flex-wrap:wrap;gap:8px;margin-top:24px}.badge{border:1px solid #ffffff45;background:#ffffff14;border-radius:999px;padding:5px 10px;font-size:12px}section{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:24px;margin-top:22px;box-shadow:0 12px 30px #17353614}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px}.card{border:1px solid var(--line);border-radius:12px;padding:16px}.muted{color:var(--muted)}code{font:13px ui-monospace,monospace;background:#edf1f0;padding:2px 5px;border-radius:5px}pre{background:var(--code);color:#d9efea;padding:18px;border-radius:12px;overflow:auto;font:13px/1.55 ui-monospace,monospace}.callout{border-left:4px solid var(--accent);background:var(--soft);padding:15px 17px;border-radius:10px;margin:16px 0}.warning{border-left-color:var(--warn);background:var(--warnbg)}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid var(--line);padding:9px 7px}.rollup label{display:block;border:1px solid var(--line);border-radius:9px;padding:9px;margin:8px 0;cursor:pointer}.result{font-size:24px;font-weight:800;color:var(--accent);margin-top:12px}.phase{border-left:3px solid var(--accent);padding-left:15px;margin:16px 0}footer{color:var(--muted);font-size:13px;padding:22px 4px}@media(max-width:650px){header,section{padding:22px}}
</style></head>
<body><main>
<header><div class="eyebrow">Developer guide · v1.3 · authoritative</div><h1>AirBridge → Mixpanel Instrumentation Mapping</h1><p class="hero">The implementation contract for Mixxi attribution across iOS and Android. This version consolidates v1.0–v1.2 and aligns the app with enums v1.4.0 ahead of Episode 0 / Discovery on <strong>31 July 2026</strong>.</p><div class="badges"><span class="badge">Prod 4017469</span><span class="badge">Staging 4017466</span><span class="badge">Dev 4002103</span><span class="badge">Events are not backfillable</span></div></header>
  var SESSIONS = [
    {
      label: "Session 1 — Instagram, week 1",
      sub: "channel=instagram, campaign=phase1_tease__ep01_discovery",
      isTaggedOpen: true,
      acquisition_source: "instagram",
      marketing_affiliate_id: null,
      mx_channel: "instagram",
      mx_phase: "phase1_tease",
      mx_episode: "ep01_discovery"
    },
    {
      label: "Session 2 — WhatsApp share tap, week 3",
      sub: "channel=whatsapp, campaign=phase3_share__ep03_finale",
      isTaggedOpen: true,
      acquisition_source: "instagram",
      marketing_affiliate_id: null,
      mx_channel: "whatsapp",
      mx_phase: "phase3_share",
      mx_episode: "ep03_finale"
    },
    {
      label: "Session 3 — Organic open, week 4",
      sub: "no link tapped — plain app open",
      isTaggedOpen: false,
      acquisition_source: "instagram",
      marketing_affiliate_id: null,
      mx_channel: "whatsapp",
      mx_phase: "phase3_share",
      mx_episode: "ep03_finale"
    }
  ];
<section><div class="label">Read this first</div><h2>The critical correction</h2><p>Earlier circulation placed the raw channel in <code>acquisition_source</code>. That is no longer valid. The raw value belongs in <code>acquisition_channel</code>; <code>acquisition_source</code> may contain only a committed enum value.</p><div class="grid"><div class="card"><h3>Raw, lossless</h3><code>acquisition_channel</code><p class="muted">Raw source such as <code>instagram</code> or <code>whatsapp</code>. First-touch and <code>$set_once</code>.</p></div><div class="card"><h3>Canonical bucket</h3><code>acquisition_source</code><p class="muted">Enum rollup from v1.4.0. First-touch and <code>$set_once</code>; never a raw channel string.</p></div><div class="card"><h3>Affiliate join key</h3><code>marketing_affiliate_id</code><p class="muted">The affiliate code from <code>campaign</code>, for affiliate links only.</p></div></div></section>
  var sessionState = { upTo: 0 };
  var sessionButtonsEl = document.getElementById("session-buttons");
  var sessionDetailEl = document.getElementById("session-detail");
<section><div class="label">§3 · interactive</div><h2>Derive the acquisition source</h2><p class="muted">Evaluate rules top-down; the first match wins.</p><div class="grid"><div class="card rollup"><label><input type="checkbox" id="affiliate"> Affiliate code is present</label><label><input type="checkbox" id="sponsor"> Funding is <code>sponsorship_funded</code></label><label><input type="radio" name="platform" value="meta" checked> Ad platform is <code>meta</code></label><label><input type="radio" name="platform" value="google_ads"> Ad platform is <code>google_ads</code></label><label><input type="checkbox" id="whatsapp"> Channel is <code>whatsapp</code></label><label><input type="checkbox" id="known"> Another known channel</label></div><div class="card"><h3>Result</h3><div class="result" id="result">unknown</div><p class="muted" id="reason">No attribution / null.</p></div></div><div class="callout warning"><strong>Paid keys off <code>ad_platform</code>, not channel.</strong> Instagram and Facebook paid traffic both roll up to <code>paid_meta</code>; the raw channel remains available in <code>acquisition_channel</code> and <code>mx_channel</code>.</div></section>
  function renderSessionButtons() {
    sessionButtonsEl.innerHTML = "";
    SESSIONS.forEach(function (s, i) {
      var btn = document.createElement("button");
      btn.type = "button";
      btn.className = "session-btn" + (i === sessionState.upTo ? " active" : "");
      btn.innerHTML = '<span class="n">' + s.label + '</span>' + s.sub;
      btn.addEventListener("click", function () { sessionState.upTo = i; renderSessionButtons(); renderSessionDetail(); });
      sessionButtonsEl.appendChild(btn);
    });
  }
<section><div class="label">§1–§2</div><h2>The chain and timing rules</h2><div class="grid"><ol><li>Consent gate first: DPDP deny-by-default.</li><li>Initialize Mixpanel before attribution work so <code>distinct_id</code> exists.</li><li>Initialize AirBridge and handle immediate and deferred deep links.</li><li>Write first-touch profile keys with <code>$set_once</code>.</li><li>Register <code>mx_*</code> super props and track funnel events.</li><li>MMRP joins the deterministic AirBridge postback to Mixpanel.</li></ol><pre>click → AirBridge → consent → Mixpanel init → AirBridge init → deep-link params → $set_once profile props → register mx_* → signup_complete</pre></div><div class="callout warning"><strong>Deferred deep links are non-negotiable.</strong> New installs receive attribution through AirBridge's deferred callback. Handling only immediate links makes tagged installs look organic.</div></section>
  function renderSessionDetail() {
    var i = sessionState.upTo;
    var current = SESSIONS[i];
    var prev = i > 0 ? SESSIONS[i - 1] : null;
<section><div class="label">§4</div><h2>Latest-touch event context</h2><p>On every attributed open, register the available context as Mixpanel super properties:</p><p><code>mx_channel</code> · <code>mx_phase</code> · <code>mx_episode</code> · <code>mx_recipe</code> · <code>mx_asset</code> · <code>mx_placement</code> · <code>mx_funding</code> · <code>mx_ad_platform</code> · <code>mx_persona</code> · <code>mx_layer</code> · <code>mx_team</code> · <code>mx_cta</code> · <code>mx_aff</code></p><div class="grid"><div class="card"><h3>Profile keys</h3><p><strong>First touch · immutable</strong></p><p><code>acquisition_channel</code>, <code>acquisition_source</code>, and affiliate credit stay fixed once stamped.</p></div><div class="card"><h3>Super properties</h3><p><strong>Latest attributed touch · overwrite</strong></p><p>Overwrite on each new tagged open. Do not clear on organic opens. Omit absent params; never write empty strings.</p></div></div></section>
    function row(field, value, kind) {
      var pill = kind === "changed"
        ? '<span class="status-pill changed">just written ($set_once — first time)</span>'
        : kind === "overwritten"
        ? '<span class="status-pill changed">overwritten — latest touch</span>'
        : kind === "carried"
        ? '<span class="status-pill carried">carried over — organic open didn\'t clear it</span>'
        : '<span class="status-pill unchanged">unchanged — first-touch immutable</span>';
      return '<tr><td class="field">' + field + '</td><td class="val">' + (value === null ? '<span class="detail-hint">null</span>' : value) + '</td><td>' + pill + '</td></tr>';
    }
<section><div class="label">§5 · launch sequencing</div><h2>What ships when</h2><div class="phase"><strong>Phase 1 — before code-freeze</strong><br>Write the raw channel to <code>acquisition_channel</code>. Leave <code>acquisition_source</code> unset. Never write a raw channel into <code>acquisition_source</code>.</div><div class="phase"><strong>Phase 2 — strongly targeted pre-launch</strong><br>Write <code>acquisition_source</code> from the JSON-encoded v1.4.0 rollup. The app must read the ruleset from JSON rather than hardcoding it.</div><div class="callout">Phase 1 is safe: raw attribution is preserved and MMRP can derive the enum server-side or on a later attributed open. What slips is dashboard convenience, not the source fact.</div></section>
    var rows = "";
    // profile fields — always first-touch, only ever "just written" on session 0, else unchanged
    rows += row("acquisition_source", current.acquisition_source, i === 0 ? "changed" : "unchanged");
    rows += row("marketing_affiliate_id", current.marketing_affiliate_id, i === 0 ? "changed" : "unchanged");
<section><div class="label">§6–§8</div><h2>Join, parity, and privacy</h2><div class="grid"><div class="card"><h3>Deterministic join</h3><p>MMRP joins via <code>airbridge_id</code> and, for affiliate credit, <code>marketing_affiliate_id</code>. No fingerprinting.</p></div><div class="card"><h3>Event parity</h3><p>Use <code>signup_complete</code>, never <code>account_created</code>. iOS and Android names and lowercase enum values must be byte-identical.</p></div><div class="card"><h3>DPDP</h3><p>No phone numbers or raw PII in Mixpanel. Never forward <code>gaid</code>, <code>device_uuid</code>, or <code>client_id</code> into Mixpanel properties.</p></div></div></section>
    ["mx_channel", "mx_phase", "mx_episode"].forEach(function (key) {
      var kind;
      if (!prev) kind = "changed";
      else if (!current.isTaggedOpen) kind = "carried";
      else if (current[key] !== prev[key]) kind = "overwritten";
      else kind = "unchanged";
      rows += row(key, current[key], kind);
    });
<section><div class="label">§9 · verification</div><h2>One decisive fresh-device test</h2><ol><li>Use a clean device and test each platform/environment. Create an affiliate link with <code>RISING-E0-TESTONLY</code>; separately test a marketing link: <code>instagram / phase1_tease__ep01_discovery / trend__testasset_01 / funding=organic</code>.</li><li>Tap → install → consent → sign up → open an episode.</li><li>In the correct Mixpanel Live View, confirm <code>signup_complete</code>, a second event with full <code>mx_*</code>, raw <code>acquisition_channel</code>, enum-only <code>acquisition_source</code>, and <code>airbridge_id</code>.</li><li>For the affiliate case, confirm exact <code>marketing_affiliate_id</code> and the AirBridge postback join.</li><li>Tap a second tagged link. Its event context must change while first-touch profile keys remain unchanged.</li></ol><div class="callout warning"><strong>Production is behind:</strong> verify all three environments before the 31 July launch. Events are not backfillable.</div></section>
    sessionDetailEl.innerHTML =
      '<div class="table-wrap"><table class="state-table"><tr><th>Field</th><th>Value after this session</th><th>What just happened</th></tr>' + rows + '</table></div>' +
      '<p class="rule" style="margin-top:14px;">' + (
        i === 0
          ? "First attributed touch: everything gets written for the first time."
          : i === 1
          ? "A new tagged link came in from a different channel. The profile's acquisition_source stays exactly as it was set on session 1 (first-touch, $set_once) — but the mx_* super props overwrite completely to reflect this session's actual campaign."
          : "No link this time — just an organic app open. Nothing gets cleared: the super props still carry session 2's context, because organic opens don't reset attribution. This is the behavior that's easy to get backwards."
      ) + '</p>';
  }
<section><div class="label">§10 · open pins</div><h2>Do not hardcode against these</h2><ol><li>Confirm the v1.4.0 JSON contains the rollup ruleset as data, not just enum members. The app team must be on v1.4.0, not v1.0.0.</li><li>Affiliate postback field (<code>partner</code> vs <code>sub_id</code>) remains console-defined and owned by Punit; <code>campaign</code> is the stable in-app source.</li><li><code>go.mixxi.ai</code> is cosmetic for deferred attribution; direct Universal/App Links are a separate freeze-gated change.</li><li>Do not build <code>vote_cast</code> client-side. Preferred path: Unravel backend emits it from validated Kafka votes.</li></ol></section>
  renderSessionButtons();
  renderSessionDetail();
})();
</script>
<footer>Source: AirBridge → Mixpanel Mapping Guide v1.3 · 23 Jul 2026 · Sourabh Mishra, MMRP · contract enums v1.4.0 · Celerity org brain</footer>
</main><script>
function derive(){let a=document.querySelector('#affiliate').checked,s=document.querySelector('#sponsor').checked,w=document.querySelector('#whatsapp').checked,k=document.querySelector('#known').checked,p=document.querySelector('input[name="platform"]:checked').value;let v='unknown',r='No attribution / null.';if(a){v='referral_marketing';r='Affiliate presence wins over every other rule.'}else if(s&&p==='meta'){v='paid_meta';r='Sponsorship-funded + Meta ad platform.'}else if(s&&p==='google_ads'){v='paid_google';r='Sponsorship-funded + Google Ads platform.'}else if(w){v='referral_marketing';r='WhatsApp is the referral-marketing channel.'}else if(k){v='organic';r='Known channel with no paid or affiliate match.'}document.querySelector('#result').textContent=v;document.querySelector('#reason').textContent=r}document.querySelectorAll('input').forEach(x=>x.addEventListener('change',derive));derive();
</script></body></html>