Bug 1536644 - Add Branched Add-on Study action r=rdalal,Gijs
authorMichael Cooper <mcooper@mozilla.com>
Fri, 14 Jun 2019 20:45:43 +0000
changeset 478955 2433254cb8221fa7d3cb8cd90bcfc8c5cb8201bf
parent 478954 94e779e03d4f1834d8b8c487d9ec170730547fcf
child 478956 bf0f5e92ef2337406d26e60f35b0836d71dce3b8
push id87998
push usermcooper@mozilla.com
push dateFri, 14 Jun 2019 21:22:46 +0000
treeherderautoland@2433254cb822 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrdalal, Gijs
bugs1536644
milestone69.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1536644 - Add Branched Add-on Study action r=rdalal,Gijs Differential Revision: https://phabricator.services.mozilla.com/D28158
toolkit/components/normandy/actions/AddonStudyAction.jsm
toolkit/components/normandy/actions/BaseAction.jsm
toolkit/components/normandy/actions/BranchedAddonStudyAction.jsm
toolkit/components/normandy/actions/schemas/index.js
toolkit/components/normandy/content/about-studies/about-studies.js
toolkit/components/normandy/lib/ActionsManager.jsm
toolkit/components/normandy/lib/AddonStudies.jsm
toolkit/components/normandy/test/browser/addons/normandydriver-1.0/manifest.json
toolkit/components/normandy/test/browser/addons/normandydriver-2.0/manifest.json
toolkit/components/normandy/test/browser/addons/normandydriver-a-1.0/manifest.json
toolkit/components/normandy/test/browser/addons/normandydriver-a-2.0/manifest.json
toolkit/components/normandy/test/browser/addons/normandydriver-b-1.0/manifest.json
toolkit/components/normandy/test/browser/browser.ini
toolkit/components/normandy/test/browser/browser_AddonStudies.js
toolkit/components/normandy/test/browser/browser_about_studies.js
toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js
toolkit/components/normandy/test/browser/head.js
toolkit/components/normandy/test/browser/moz.build
toolkit/components/telemetry/Events.yaml
toolkit/components/utils/JsonSchemaValidator.jsm
toolkit/components/utils/test/browser/browser_JsonSchemaValidator.js
--- a/toolkit/components/normandy/actions/AddonStudyAction.jsm
+++ b/toolkit/components/normandy/actions/AddonStudyAction.jsm
@@ -1,508 +1,105 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-/*
- * This action handles the life cycle of add-on based studies. Currently that
- * means installing the add-on the first time the recipe applies to this client,
- * and uninstalling them when the recipe no longer applies.
- */
-
 "use strict";
 
 const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
-const {BaseAction} = ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
+const {BranchedAddonStudyAction} = ChromeUtils.import("resource://normandy/actions/BranchedAddonStudyAction.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
-  Services: "resource://gre/modules/Services.jsm",
-  PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
-  AddonManager: "resource://gre/modules/AddonManager.jsm",
   ActionSchemas: "resource://normandy/actions/schemas/index.js",
   AddonStudies: "resource://normandy/lib/AddonStudies.jsm",
-  NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
-  TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
 });
 
 var EXPORTED_SYMBOLS = ["AddonStudyAction"];
 
-const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled";
-
-class AddonStudyEnrollError extends Error {
-  /**
-   * @param {string} studyName
-   * @param {object} extra Extra details to include when reporting the error to telemetry.
-   * @param {string} extra.reason The specific reason for the failure.
-   */
-  constructor(studyName, extra) {
-    let message;
-    let { reason } = extra;
-    switch (reason) {
-      case "conflicting-addon-id": {
-        message = "an add-on with this ID is already installed";
-        break;
-      }
-      case "download-failure": {
-        message = "the add-on failed to download";
-        break;
-      }
-      case "metadata-mismatch": {
-        message = "the server metadata does not match the downloaded add-on";
-        break;
-      }
-      case "install-failure": {
-        message = "the add-on failed to install";
-        break;
-      }
-      default: {
-        throw new Error(`Unexpected AddonStudyEnrollError reason: ${reason}`);
-      }
-    }
-    super(`Cannot install study add-on for ${studyName}: ${message}.`);
-    this.studyName = studyName;
-    this.extra = extra;
-  }
-}
+/*
+ * This action was originally the only form of add-on studies. Later, a version
+ * of add-on studies was addded that supported having more than one
+ * experimental branch per study, instead of relying on the installed add-on to
+ * manage its branches. To reduce duplicated code, the no-branches version of
+ * the action inherits from the multi-branch version.
+ *
+ * The schemas of the arguments for these two actions are different. As well as
+ * supporting branches within the study, the multi-branch version also changed
+ * its metadata fields to better match the use cases of studies.
+ *
+ * This action translates a legacy no branches study into a single branched
+ * study with the proper metadata. This should be considered a temporary
+ * measure, and eventually all studies will be native multi-branch studies.
+ *
+ * The existing schema can't be changed, because these legacy recipes are also
+ * sent by the server to older clients that don't support the newer schema
+ * format.
+ */
 
-class AddonStudyUpdateError extends Error {
-  /**
-   * @param {string} studyName
-   * @param {object} extra Extra details to include when reporting the error to telemetry.
-   * @param {string} extra.reason The specific reason for the failure.
-   */
-  constructor(studyName, extra) {
-    let message;
-    let { reason } = extra;
-    switch (reason) {
-      case "addon-id-mismatch": {
-        message = "new add-on ID does not match old add-on ID";
-        break;
-      }
-      case "addon-does-not-exist": {
-        message = "an add-on with this ID does not exist";
-        break;
-      }
-      case "no-downgrade": {
-        message = "the add-on was an older version than is installed";
-        break;
-      }
-      case "metadata-mismatch": {
-        message = "the server metadata does not match the downloaded add-on";
-        break;
-      }
-      case "download-failure": {
-        message = "the add-on failed to download";
-        break;
-      }
-      case "install-failure": {
-        message = "the add-on failed to install";
-        break;
-      }
-      default: {
-        throw new Error(`Unexpected AddonStudyUpdateError reason: ${reason}`);
-      }
-    }
-    super(`Cannot update study add-on for ${studyName}: ${message}.`);
-    this.studyName = studyName;
-    this.extra = extra;
-  }
-}
-
-class AddonStudyAction extends BaseAction {
+class AddonStudyAction extends BranchedAddonStudyAction {
   get schema() {
     return ActionSchemas["addon-study"];
   }
 
-  constructor() {
-    super();
-    this.seenRecipeIds = new Set();
-  }
-
-  /**
-   * This hook is executed once before any recipes have been processed, it is
-   * responsible for:
-   *
-   *   - Checking if the user has opted out of studies, and if so, it disables the action.
-   *   - Setting up tracking of seen recipes, for use in _finalize.
-   */
-  _preExecution() {
-    // Check opt-out preference
-    if (!Services.prefs.getBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, true)) {
-      this.log.info("User has opted-out of opt-out experiments, disabling action.");
-      this.disable();
-    }
-  }
-
   /**
    * This hook is executed once for each recipe that currently applies to this
    * client. It is responsible for:
    *
-   *   - Enrolling studies the first time they are seen.
-   *   - Updating studies that have upgraded addons.
-   *   - Marking studies as having been seen in this session.
+   *   - Translating recipes to match BranchedAddonStudy's schema
+   *   - Validating that transformation
+   *   - Calling BranchedAddonStudy's _run hook.
    *
-   * If the recipe fails to enroll or update, it should throw to properly report its status.
+   * If the recipe fails to enroll or update, it should throw to properly
+   * report its status.
    */
   async _run(recipe) {
-    this.seenRecipeIds.add(recipe.id);
-
-    const hasStudy = await AddonStudies.has(recipe.id);
-    const { extensionApiId } = recipe.arguments;
-    const extensionDetails = await NormandyApi.fetchExtensionDetails(extensionApiId);
+    const args = recipe.arguments; // save some typing
 
-    if (hasStudy) {
-      await this.update(recipe, extensionDetails);
-    } else {
-      await this.enroll(recipe, extensionDetails);
-    }
+    /*
+     * The argument schema of no-branches add-ons don't include a separate slug
+     * and name, and use different names for the description. Convert from the
+     * old to the new one.
+     */
+    let transformedArguments = {
+      slug: args.name,
+      userFacingName: args.name,
+      userFacingDescription: args.description,
+      isEnrollmentPaused: !!args.isEnrollmentPaused,
+      branches: [
+        {
+          slug: AddonStudies.NO_BRANCHES_MARKER,
+          ratio: 1,
+          extensionApiId: recipe.arguments.extensionApiId,
+        },
+      ],
+    };
+
+    // This will throw if the arguments aren't valid, and BaseAction will catch it.
+    transformedArguments = this.validateArguments(
+      transformedArguments,
+      ActionSchemas["branched-addon-study"],
+    );
+
+    const transformedRecipe = {...recipe, arguments: transformedArguments};
+    return super._run(transformedRecipe);
   }
 
   /**
    * This hook is executed once after all recipes that apply to this client
    * have been processed. It is responsible for unenrolling the client from any
-   * studies that no longer apply, based on this.seenRecipeIds.
+   * studies that no longer apply, based on this.seenRecipeIds, which is set by
+   * the super class.
    */
   async _finalize() {
-    const activeStudies = (await AddonStudies.getAll()).filter(study => study.active);
+    const activeStudies = await AddonStudies.getAllActive({ branched: AddonStudies.FILTER_NOT_BRANCHED });
 
     for (const study of activeStudies) {
       if (!this.seenRecipeIds.has(study.recipeId)) {
-        this.log.debug(`Stopping study for recipe ${study.recipeId}`);
+        this.log.debug(`Stopping non-branched add-on study for recipe ${study.recipeId}`);
         try {
           await this.unenroll(study.recipeId, "recipe-not-seen");
         } catch (err) {
           Cu.reportError(err);
         }
       }
     }
   }
-
-  /**
-   * Download and install the addon for a given recipe
-   *
-   * @param recipe Object describing the study to enroll in.
-   * @param extensionDetails Object describing the addon to be installed.
-   * @param onInstallStarted A function that returns a callback for the install listener.
-   * @param onComplete A callback function that is run on completion of the download.
-   * @param onFailedInstall A callback function that is run if the installation fails.
-   * @param errorClass The class of error to be thrown when exceptions occur.
-   * @param reportError A function that reports errors to Telemetry.
-   */
-  async downloadAndInstall(recipe, extensionDetails, onInstallStarted, onComplete, onFailedInstall, errorClass, reportError) {
-    const { name } = recipe.arguments;
-    const { hash, hash_algorithm } = extensionDetails;
-
-    const downloadDeferred = PromiseUtils.defer();
-    const installDeferred = PromiseUtils.defer();
-
-    const install = await AddonManager.getInstallForURL(extensionDetails.xpi, {
-      hash: `${hash_algorithm}:${hash}`,
-      telemetryInfo: {source: "internal"},
-    });
-
-    const listener = {
-      onDownloadFailed() {
-        downloadDeferred.reject(new errorClass(name, {
-          reason: "download-failure",
-          detail: AddonManager.errorToString(install.error),
-        }));
-      },
-
-      onDownloadEnded() {
-        downloadDeferred.resolve();
-        return false; // temporarily pause installation for Normandy bookkeeping
-      },
-
-      onInstallFailed() {
-        installDeferred.reject(new errorClass(name, {
-          reason: "install-failure",
-          detail: AddonManager.errorToString(install.error),
-        }));
-      },
-
-      onInstallEnded() {
-        installDeferred.resolve();
-      },
-    };
-
-    listener.onInstallStarted = onInstallStarted(installDeferred);
-
-    install.addListener(listener);
-
-    // Download the add-on
-    try {
-      install.install();
-      await downloadDeferred.promise;
-    } catch (err) {
-      reportError(err);
-      install.removeListener(listener);
-      throw err;
-    }
-
-    await onComplete(install, listener);
-
-    // Finish paused installation
-    try {
-      install.install();
-      await installDeferred.promise;
-    } catch (err) {
-      reportError(err);
-      install.removeListener(listener);
-      await onFailedInstall();
-      throw err;
-    }
-
-    install.removeListener(listener);
-
-    return [install.addon.id, install.addon.version];
-  }
-
-  /**
-   * Enroll in the study represented by the given recipe.
-   * @param recipe Object describing the study to enroll in.
-   * @param extensionDetails Object describing the addon to be installed.
-   */
-  async enroll(recipe, extensionDetails) {
-    // This function first downloads the add-on to get its metadata. Then it
-    // uses that metadata to record a study in `AddonStudies`. Then, it finishes
-    // installing the add-on, and finally sends telemetry. If any of these steps
-    // fails, the previous ones are undone, as needed.
-    //
-    // This ordering is important because the only intermediate states we can be
-    // in are:
-    //   1. The add-on is only downloaded, in which case AddonManager will clean it up.
-    //   2. The study has been recorded, in which case we will unenroll on next
-    //      start up, assuming that the add-on was uninstalled while the browser was
-    //      shutdown.
-    //   3. After installation is complete, but before telemetry, in which case we
-    //      lose an enroll event. This is acceptable.
-    //
-    // This way we a shutdown, crash or unexpected error can't leave Normandy in
-    // a long term inconsistent state. The main thing avoided is having a study
-    // add-on installed but no record of it, which would leave it permanently
-    // installed.
-
-    if (recipe.arguments.isEnrollmentPaused) {
-      // Recipe does not need anything done
-      return;
-    }
-
-    const { extensionApiId, name, description } = recipe.arguments;
-
-    const onInstallStarted = installDeferred => {
-      return cbInstall => {
-        const versionMatches = cbInstall.addon.version === extensionDetails.version;
-        const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
-
-        if (cbInstall.existingAddon) {
-          installDeferred.reject(new AddonStudyEnrollError(name, {reason: "conflicting-addon-id"}));
-          return false; // cancel the installation, no upgrades allowed
-        } else if (!versionMatches || !idMatches) {
-          installDeferred.reject(new AddonStudyEnrollError(name, {
-            reason: "metadata-mismatch",
-          }));
-          return false; // cancel the installation, server metadata do not match downloaded add-on
-        }
-        return true;
-      };
-    };
-
-    const onComplete = async (install, listener) => {
-      const study = {
-        recipeId: recipe.id,
-        name,
-        description,
-        addonId: install.addon.id,
-        addonVersion: install.addon.version,
-        addonUrl: extensionDetails.xpi,
-        extensionApiId,
-        extensionHash: extensionDetails.hash,
-        extensionHashAlgorithm: extensionDetails.hash_algorithm,
-        active: true,
-        studyStartDate: new Date(),
-      };
-
-      try {
-        await AddonStudies.add(study);
-      } catch (err) {
-        this.reportEnrollError(err);
-        install.removeListener(listener);
-        install.cancel();
-        throw err;
-      }
-    };
-
-    const onFailedInstall = async () => {
-      await AddonStudies.delete(recipe.id);
-    };
-
-    const [installedId, installedVersion] = await this.downloadAndInstall(
-      recipe,
-      extensionDetails,
-      onInstallStarted,
-      onComplete,
-      onFailedInstall,
-      AddonStudyEnrollError,
-      this.reportEnrollError,
-    );
-
-    // All done, report success to Telemetry
-    TelemetryEvents.sendEvent("enroll", "addon_study", name, {
-      addonId: installedId,
-      addonVersion: installedVersion,
-    });
-  }
-
-  /**
-   * Update the study represented by the given recipe.
-   * @param recipe Object describing the study to be updated.
-   * @param extensionDetails Object describing the addon to be installed.
-   */
-  async update(recipe, extensionDetails) {
-    const study = await AddonStudies.get(recipe.id);
-    const { extensionApiId, name } = recipe.arguments;
-
-    let error;
-
-    if (study.addonId !== extensionDetails.extension_id) {
-      error = new AddonStudyUpdateError(name, {
-        reason: "addon-id-mismatch",
-      });
-    }
-
-    const versionCompare = Services.vc.compare(study.addonVersion, extensionDetails.version);
-    if (versionCompare > 0) {
-      error = new AddonStudyUpdateError(name, {
-        reason: "no-downgrade",
-      });
-    } else if (versionCompare === 0) {
-      return; // Unchanged, do nothing
-    }
-
-    if (error) {
-      this.reportUpdateError(error);
-      throw error;
-    }
-
-    const onInstallStarted = installDeferred => {
-      return cbInstall => {
-        const versionMatches = cbInstall.addon.version === extensionDetails.version;
-        const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
-
-        if (!cbInstall.existingAddon) {
-          installDeferred.reject(new AddonStudyUpdateError(name, {
-            reason: "addon-does-not-exist",
-          }));
-          return false; // cancel the installation, must upgrade an existing add-on
-        } else if (!versionMatches || !idMatches) {
-          installDeferred.reject(new AddonStudyUpdateError(name, {
-            reason: "metadata-mismatch",
-          }));
-          return false; // cancel the installation, server metadata do not match downloaded add-on
-        }
-
-        return true;
-      };
-    };
-
-    const onComplete = async (install, listener) => {
-      try {
-        await AddonStudies.update({
-          ...study,
-          addonVersion: install.addon.version,
-          addonUrl: extensionDetails.xpi,
-          extensionHash: extensionDetails.hash,
-          extensionHashAlgorithm: extensionDetails.hash_algorithm,
-          extensionApiId,
-        });
-      } catch (err) {
-        this.reportUpdateError(err);
-        install.removeListener(listener);
-        install.cancel();
-        throw err;
-      }
-    };
-
-    const onFailedInstall = () => {
-      AddonStudies.update(study);
-    };
-
-    const [installedId, installedVersion] = await this.downloadAndInstall(
-      recipe,
-      extensionDetails,
-      onInstallStarted,
-      onComplete,
-      onFailedInstall,
-      AddonStudyUpdateError,
-      this.reportUpdateError,
-    );
-
-    // All done, report success to Telemetry
-    TelemetryEvents.sendEvent("update", "addon_study", name, {
-      addonId: installedId,
-      addonVersion: installedVersion,
-    });
-  }
-
-  reportEnrollError(error) {
-    if (error instanceof AddonStudyEnrollError) {
-      // One of our known errors. Report it nicely to telemetry
-      TelemetryEvents.sendEvent("enrollFailed", "addon_study", error.studyName, error.extra);
-    } else {
-      /*
-        * Some unknown error. Add some helpful details, and report it to
-        * telemetry. The actual stack trace and error message could possibly
-        * contain PII, so we don't include them here. Instead include some
-        * information that should still be helpful, and is less likely to be
-        * unsafe.
-        */
-      const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
-      TelemetryEvents.sendEvent("enrollFailed", "addon_study", error.studyName, {
-        reason: safeErrorMessage.slice(0, 80),  // max length is 80 chars
-      });
-    }
-  }
-
-  reportUpdateError(error) {
-    if (error instanceof AddonStudyUpdateError) {
-      // One of our known errors. Report it nicely to telemetry
-      TelemetryEvents.sendEvent("updateFailed", "addon_study", error.studyName, error.extra);
-    } else {
-      /*
-        * Some unknown error. Add some helpful details, and report it to
-        * telemetry. The actual stack trace and error message could possibly
-        * contain PII, so we don't include them here. Instead include some
-        * information that should still be helpful, and is less likely to be
-        * unsafe.
-        */
-      const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
-      TelemetryEvents.sendEvent("updateFailed", "addon_study", error.studyName, {
-        reason: safeErrorMessage.slice(0, 80),  // max length is 80 chars
-      });
-    }
-  }
-
-  /**
-   * Unenrolls the client from the study with a given recipe ID.
-   * @param recipeId The recipe ID of an enrolled study
-   * @param reason The reason for this unenrollment, to be used in Telemetry
-   * @throws If the specified study does not exist, or if it is already inactive.
-   */
-  async unenroll(recipeId, reason = "unknown") {
-    const study = await AddonStudies.get(recipeId);
-    if (!study) {
-      throw new Error(`No study found for recipe ${recipeId}.`);
-    }
-    if (!study.active) {
-      throw new Error(`Cannot stop study for recipe ${recipeId}; it is already inactive.`);
-    }
-
-    await AddonStudies.markAsEnded(study, reason);
-
-    const addon = await AddonManager.getAddonByID(study.addonId);
-    if (addon) {
-      await addon.uninstall();
-    } else {
-      this.log.warn(`Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}: it is not installed.`);
-    }
-  }
 }
--- a/toolkit/components/normandy/actions/BaseAction.jsm
+++ b/toolkit/components/normandy/actions/BaseAction.jsm
@@ -93,16 +93,27 @@ class BaseAction {
   /**
    * Action specific pre-execution behavior should be implemented
    * here. It will be called once per execution session.
    */
   _preExecution() {
     // Does nothing, may be overridden
   }
 
+  validateArguments(args, schema = this.schema) {
+    let [valid, validated] = JsonSchemaValidator.validateAndParseParameters(args, schema);
+    if (!valid) {
+      throw new Error(
+        `Arguments do not match schema. arguments:\n${JSON.stringify(args)}\n`
+        + `schema:\n${JSON.stringify(schema)}`
+      );
+    }
+    return validated;
+  }
+
   /**
    * Execute the per-recipe behavior of this action for a given
    * recipe.  Reports Uptake telemetry for the execution of the recipe.
    *
    * @param {Recipe} recipe
    * @throws If this action has already been finalized.
    */
   async runRecipe(recipe) {
@@ -113,25 +124,24 @@ class BaseAction {
     }
 
     if (this.state !== BaseAction.STATE_READY) {
       Uptake.reportRecipe(recipe, Uptake.RECIPE_ACTION_DISABLED);
       this.log.warn(`Skipping recipe ${recipe.name} because ${this.name} was disabled during preExecution.`);
       return;
     }
 
-    let [valid, validatedArguments] = JsonSchemaValidator.validateAndParseParameters(recipe.arguments, this.schema);
-    if (!valid) {
-      Cu.reportError(new Error(`Arguments do not match schema. arguments: ${JSON.stringify(recipe.arguments)}. schema: ${JSON.stringify(this.schema)}`));
+    try {
+      recipe.arguments = this.validateArguments(recipe.arguments);
+    } catch (error) {
+      Cu.reportError(error);
       Uptake.reportRecipe(recipe, Uptake.RECIPE_EXECUTION_ERROR);
       return;
     }
 
-    recipe.arguments = validatedArguments;
-
     let status = Uptake.RECIPE_SUCCESS;
     try {
       await this._run(recipe);
     } catch (err) {
       Cu.reportError(err);
       status = Uptake.RECIPE_EXECUTION_ERROR;
     }
     Uptake.reportRecipe(recipe, status);
copy from toolkit/components/normandy/actions/AddonStudyAction.jsm
copy to toolkit/components/normandy/actions/BranchedAddonStudyAction.jsm
--- a/toolkit/components/normandy/actions/AddonStudyAction.jsm
+++ b/toolkit/components/normandy/actions/BranchedAddonStudyAction.jsm
@@ -1,34 +1,38 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /*
  * This action handles the life cycle of add-on based studies. Currently that
- * means installing the add-on the first time the recipe applies to this client,
- * and uninstalling them when the recipe no longer applies.
+ * means installing the add-on the first time the recipe applies to this
+ * client, updating the add-on to new versions if the recipe changes, and
+ * uninstalling them when the recipe no longer applies.
  */
 
 "use strict";
 
 const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 const {BaseAction} = ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
-  Services: "resource://gre/modules/Services.jsm",
-  PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
+  ActionSchemas: "resource://normandy/actions/schemas/index.js",
   AddonManager: "resource://gre/modules/AddonManager.jsm",
-  ActionSchemas: "resource://normandy/actions/schemas/index.js",
   AddonStudies: "resource://normandy/lib/AddonStudies.jsm",
+  ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
   NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
+  PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
+  Sampling: "resource://gre/modules/components-utils/Sampling.jsm",
+  Services: "resource://gre/modules/Services.jsm",
+  TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
   TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
 });
 
-var EXPORTED_SYMBOLS = ["AddonStudyAction"];
+var EXPORTED_SYMBOLS = ["BranchedAddonStudyAction"];
 
 const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled";
 
 class AddonStudyEnrollError extends Error {
   /**
    * @param {string} studyName
    * @param {object} extra Extra details to include when reporting the error to telemetry.
    * @param {string} extra.reason The specific reason for the failure.
@@ -102,19 +106,19 @@ class AddonStudyUpdateError extends Erro
       }
     }
     super(`Cannot update study add-on for ${studyName}: ${message}.`);
     this.studyName = studyName;
     this.extra = extra;
   }
 }
 
-class AddonStudyAction extends BaseAction {
+class BranchedAddonStudyAction extends BaseAction {
   get schema() {
-    return ActionSchemas["addon-study"];
+    return ActionSchemas["branched-addon-study"];
   }
 
   constructor() {
     super();
     this.seenRecipeIds = new Set();
   }
 
   /**
@@ -139,39 +143,35 @@ class AddonStudyAction extends BaseActio
    *   - Enrolling studies the first time they are seen.
    *   - Updating studies that have upgraded addons.
    *   - Marking studies as having been seen in this session.
    *
    * If the recipe fails to enroll or update, it should throw to properly report its status.
    */
   async _run(recipe) {
     this.seenRecipeIds.add(recipe.id);
-
-    const hasStudy = await AddonStudies.has(recipe.id);
-    const { extensionApiId } = recipe.arguments;
-    const extensionDetails = await NormandyApi.fetchExtensionDetails(extensionApiId);
-
-    if (hasStudy) {
-      await this.update(recipe, extensionDetails);
+    const study = await AddonStudies.get(recipe.id);
+    if (study) {
+      await this.update(recipe, study);
     } else {
-      await this.enroll(recipe, extensionDetails);
+      await this.enroll(recipe);
     }
   }
 
   /**
    * This hook is executed once after all recipes that apply to this client
    * have been processed. It is responsible for unenrolling the client from any
    * studies that no longer apply, based on this.seenRecipeIds.
    */
   async _finalize() {
-    const activeStudies = (await AddonStudies.getAll()).filter(study => study.active);
+    const activeStudies = await AddonStudies.getAllActive({ branched: AddonStudies.FILTER_BRANCHED_ONLY });
 
     for (const study of activeStudies) {
       if (!this.seenRecipeIds.has(study.recipeId)) {
-        this.log.debug(`Stopping study for recipe ${study.recipeId}`);
+        this.log.debug(`Stopping branched add-on study for recipe ${study.recipeId}`);
         try {
           await this.unenroll(study.recipeId, "recipe-not-seen");
         } catch (err) {
           Cu.reportError(err);
         }
       }
     }
   }
@@ -182,44 +182,55 @@ class AddonStudyAction extends BaseActio
    * @param recipe Object describing the study to enroll in.
    * @param extensionDetails Object describing the addon to be installed.
    * @param onInstallStarted A function that returns a callback for the install listener.
    * @param onComplete A callback function that is run on completion of the download.
    * @param onFailedInstall A callback function that is run if the installation fails.
    * @param errorClass The class of error to be thrown when exceptions occur.
    * @param reportError A function that reports errors to Telemetry.
    */
-  async downloadAndInstall(recipe, extensionDetails, onInstallStarted, onComplete, onFailedInstall, errorClass, reportError) {
-    const { name } = recipe.arguments;
+  async downloadAndInstall({
+    recipe,
+    extensionDetails,
+    branchSlug,
+    onInstallStarted,
+    onComplete,
+    onFailedInstall,
+    errorClass,
+    reportError,
+  }) {
+    const { slug } = recipe.arguments;
     const { hash, hash_algorithm } = extensionDetails;
 
     const downloadDeferred = PromiseUtils.defer();
     const installDeferred = PromiseUtils.defer();
 
     const install = await AddonManager.getInstallForURL(extensionDetails.xpi, {
       hash: `${hash_algorithm}:${hash}`,
       telemetryInfo: {source: "internal"},
     });
 
     const listener = {
       onDownloadFailed() {
-        downloadDeferred.reject(new errorClass(name, {
+        downloadDeferred.reject(new errorClass(slug, {
           reason: "download-failure",
+          branch: branchSlug,
           detail: AddonManager.errorToString(install.error),
         }));
       },
 
       onDownloadEnded() {
         downloadDeferred.resolve();
         return false; // temporarily pause installation for Normandy bookkeeping
       },
 
       onInstallFailed() {
-        installDeferred.reject(new errorClass(name, {
+        installDeferred.reject(new errorClass(slug, {
           reason: "install-failure",
+          branch: branchSlug,
           detail: AddonManager.errorToString(install.error),
         }));
       },
 
       onInstallEnded() {
         installDeferred.resolve();
       },
     };
@@ -251,200 +262,279 @@ class AddonStudyAction extends BaseActio
       throw err;
     }
 
     install.removeListener(listener);
 
     return [install.addon.id, install.addon.version];
   }
 
+  async chooseBranch({ slug, branches }) {
+    const ratios = branches.map(branch => branch.ratio);
+    const userId = ClientEnvironment.userId;
+
+    // It's important that the input be:
+    // - Unique per-user (no one is bucketed alike)
+    // - Unique per-experiment (bucketing differs across multiple experiments)
+    // - Differs from the input used for sampling the recipe (otherwise only
+    //   branches that contain the same buckets as the recipe sampling will
+    //   receive users)
+    const input = `${userId}-${slug}-addon-branch`;
+
+    const index = await Sampling.ratioSample(input, ratios);
+    return branches[index];
+  }
+
   /**
    * Enroll in the study represented by the given recipe.
    * @param recipe Object describing the study to enroll in.
    * @param extensionDetails Object describing the addon to be installed.
    */
-  async enroll(recipe, extensionDetails) {
+  async enroll(recipe) {
     // This function first downloads the add-on to get its metadata. Then it
     // uses that metadata to record a study in `AddonStudies`. Then, it finishes
     // installing the add-on, and finally sends telemetry. If any of these steps
     // fails, the previous ones are undone, as needed.
     //
     // This ordering is important because the only intermediate states we can be
     // in are:
     //   1. The add-on is only downloaded, in which case AddonManager will clean it up.
     //   2. The study has been recorded, in which case we will unenroll on next
-    //      start up, assuming that the add-on was uninstalled while the browser was
-    //      shutdown.
+    //      start up. The start up code will assume that the add-on was uninstalled
+    //      while the browser was shutdown.
     //   3. After installation is complete, but before telemetry, in which case we
     //      lose an enroll event. This is acceptable.
     //
-    // This way we a shutdown, crash or unexpected error can't leave Normandy in
-    // a long term inconsistent state. The main thing avoided is having a study
+    // This way a shutdown, crash or unexpected error can't leave Normandy in a
+    // long term inconsistent state. The main thing avoided is having a study
     // add-on installed but no record of it, which would leave it permanently
     // installed.
 
     if (recipe.arguments.isEnrollmentPaused) {
       // Recipe does not need anything done
       return;
     }
 
-    const { extensionApiId, name, description } = recipe.arguments;
-
-    const onInstallStarted = installDeferred => {
-      return cbInstall => {
-        const versionMatches = cbInstall.addon.version === extensionDetails.version;
-        const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
+    const { slug, userFacingName, userFacingDescription } = recipe.arguments;
+    const branch = await this.chooseBranch({
+      slug: recipe.arguments.slug,
+      branches: recipe.arguments.branches,
+    });
+    this.log.debug(`Enrolling in branch ${branch.slug}`);
 
-        if (cbInstall.existingAddon) {
-          installDeferred.reject(new AddonStudyEnrollError(name, {reason: "conflicting-addon-id"}));
-          return false; // cancel the installation, no upgrades allowed
-        } else if (!versionMatches || !idMatches) {
-          installDeferred.reject(new AddonStudyEnrollError(name, {
-            reason: "metadata-mismatch",
-          }));
-          return false; // cancel the installation, server metadata do not match downloaded add-on
-        }
-        return true;
-      };
-    };
-
-    const onComplete = async (install, listener) => {
+    if (branch.extensionApiId === null) {
       const study = {
         recipeId: recipe.id,
-        name,
-        description,
-        addonId: install.addon.id,
-        addonVersion: install.addon.version,
-        addonUrl: extensionDetails.xpi,
-        extensionApiId,
-        extensionHash: extensionDetails.hash,
-        extensionHashAlgorithm: extensionDetails.hash_algorithm,
+        slug,
+        userFacingName,
+        userFacingDescription,
+        branch: branch.slug,
+        addonId: null,
+        addonVersion: null,
+        addonUrl: null,
+        extensionApiId: null,
+        extensionHash: null,
+        extensionHashAlgorithm: null,
         active: true,
         studyStartDate: new Date(),
+        studyEndDate: null,
       };
 
       try {
         await AddonStudies.add(study);
       } catch (err) {
         this.reportEnrollError(err);
-        install.removeListener(listener);
-        install.cancel();
         throw err;
       }
-    };
+
+      // All done, report success to Telemetry
+      TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
+        addonId: AddonStudies.NO_ADDON_MARKER,
+        addonVersion: AddonStudies.NO_ADDON_MARKER,
+        branch: branch.slug,
+      });
+    } else {
+      const extensionDetails = await NormandyApi.fetchExtensionDetails(branch.extensionApiId);
+
+      const onInstallStarted = installDeferred => cbInstall => {
+        const versionMatches = cbInstall.addon.version === extensionDetails.version;
+        const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
 
-    const onFailedInstall = async () => {
-      await AddonStudies.delete(recipe.id);
-    };
+        if (cbInstall.existingAddon) {
+          installDeferred.reject(new AddonStudyEnrollError(slug, {
+            reason: "conflicting-addon-id",
+            branch: branch.slug,
+          }));
+          return false; // cancel the installation, no upgrades allowed
+        } else if (!versionMatches || !idMatches) {
+          installDeferred.reject(new AddonStudyEnrollError(slug, {
+            branch: branch.slug,
+            reason: "metadata-mismatch",
+          }));
+          return false; // cancel the installation, server metadata does not match downloaded add-on
+        }
+        return true;
+      };
 
-    const [installedId, installedVersion] = await this.downloadAndInstall(
-      recipe,
-      extensionDetails,
-      onInstallStarted,
-      onComplete,
-      onFailedInstall,
-      AddonStudyEnrollError,
-      this.reportEnrollError,
-    );
+      const onComplete = async (install, listener) => {
+        const study = {
+          recipeId: recipe.id,
+          slug,
+          userFacingName,
+          userFacingDescription,
+          branch: branch.slug,
+          addonId: install.addon.id,
+          addonVersion: install.addon.version,
+          addonUrl: extensionDetails.xpi,
+          extensionApiId: branch.extensionApiId,
+          extensionHash: extensionDetails.hash,
+          extensionHashAlgorithm: extensionDetails.hash_algorithm,
+          active: true,
+          studyStartDate: new Date(),
+          studyEndDate: null,
+        };
 
-    // All done, report success to Telemetry
-    TelemetryEvents.sendEvent("enroll", "addon_study", name, {
-      addonId: installedId,
-      addonVersion: installedVersion,
-    });
+        try {
+          await AddonStudies.add(study);
+        } catch (err) {
+          this.reportEnrollError(err);
+          install.removeListener(listener);
+          install.cancel();
+          throw err;
+        }
+      };
+
+      const onFailedInstall = async () => {
+        await AddonStudies.delete(recipe.id);
+      };
+
+      const [installedId, installedVersion] = await this.downloadAndInstall({
+        recipe,
+        branchSlug: branch.slug,
+        extensionDetails,
+        onInstallStarted,
+        onComplete,
+        onFailedInstall,
+        errorClass: AddonStudyEnrollError,
+        reportError: this.reportEnrollError,
+      });
+
+      // All done, report success to Telemetry
+      TelemetryEvents.sendEvent("enroll", "addon_study", slug, {
+        addonId: installedId,
+        addonVersion: installedVersion,
+        branch: branch.slug,
+      });
+    }
+
+    TelemetryEnvironment.setExperimentActive(slug, branch.slug, {type: "normandy-addonstudy"});
   }
 
   /**
    * Update the study represented by the given recipe.
    * @param recipe Object describing the study to be updated.
    * @param extensionDetails Object describing the addon to be installed.
    */
-  async update(recipe, extensionDetails) {
-    const study = await AddonStudies.get(recipe.id);
-    const { extensionApiId, name } = recipe.arguments;
+  async update(recipe, study) {
+    const { slug } = recipe.arguments;
+
+    // Stay in the same branch, don't re-sample every time.
+    const branch = recipe.arguments.branches.find(branch => branch.slug === study.branch);
+
+    if (!branch) {
+      // Our branch has been removed. Unenroll.
+      await this.unenroll(recipe.id, "branch-removed");
+      return;
+    }
+
+    const extensionDetails = await NormandyApi.fetchExtensionDetails(branch.extensionApiId);
 
     let error;
 
-    if (study.addonId !== extensionDetails.extension_id) {
-      error = new AddonStudyUpdateError(name, {
+    if (study.addonId && study.addonId !== extensionDetails.extension_id) {
+      error = new AddonStudyUpdateError(slug, {
+        branch: branch.slug,
         reason: "addon-id-mismatch",
       });
     }
 
     const versionCompare = Services.vc.compare(study.addonVersion, extensionDetails.version);
     if (versionCompare > 0) {
-      error = new AddonStudyUpdateError(name, {
+      error = new AddonStudyUpdateError(slug, {
+        branch: branch.slug,
         reason: "no-downgrade",
       });
     } else if (versionCompare === 0) {
       return; // Unchanged, do nothing
     }
 
     if (error) {
       this.reportUpdateError(error);
       throw error;
     }
 
-    const onInstallStarted = installDeferred => {
-      return cbInstall => {
-        const versionMatches = cbInstall.addon.version === extensionDetails.version;
-        const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
+    const onInstallStarted = installDeferred => cbInstall => {
+      const versionMatches = cbInstall.addon.version === extensionDetails.version;
+      const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
 
-        if (!cbInstall.existingAddon) {
-          installDeferred.reject(new AddonStudyUpdateError(name, {
-            reason: "addon-does-not-exist",
-          }));
-          return false; // cancel the installation, must upgrade an existing add-on
-        } else if (!versionMatches || !idMatches) {
-          installDeferred.reject(new AddonStudyUpdateError(name, {
-            reason: "metadata-mismatch",
-          }));
-          return false; // cancel the installation, server metadata do not match downloaded add-on
-        }
+      if (!cbInstall.existingAddon) {
+        installDeferred.reject(new AddonStudyUpdateError(slug, {
+          branch: branch.slug,
+          reason: "addon-does-not-exist",
+        }));
+        return false; // cancel the installation, must upgrade an existing add-on
+      } else if (!versionMatches || !idMatches) {
+        installDeferred.reject(new AddonStudyUpdateError(slug, {
+          branch: branch.slug,
+          reason: "metadata-mismatch",
+        }));
+        return false; // cancel the installation, server metadata do not match downloaded add-on
+      }
 
-        return true;
-      };
+      return true;
     };
 
     const onComplete = async (install, listener) => {
       try {
         await AddonStudies.update({
           ...study,
           addonVersion: install.addon.version,
           addonUrl: extensionDetails.xpi,
           extensionHash: extensionDetails.hash,
           extensionHashAlgorithm: extensionDetails.hash_algorithm,
-          extensionApiId,
+          extensionApiId: branch.extensionApiId,
         });
       } catch (err) {
         this.reportUpdateError(err);
         install.removeListener(listener);
         install.cancel();
         throw err;
       }
     };
 
     const onFailedInstall = () => {
       AddonStudies.update(study);
     };
 
-    const [installedId, installedVersion] = await this.downloadAndInstall(
+    const [installedId, installedVersion] = await this.downloadAndInstall({
       recipe,
       extensionDetails,
+      branchSlug: branch.slug,
       onInstallStarted,
       onComplete,
       onFailedInstall,
-      AddonStudyUpdateError,
-      this.reportUpdateError,
-    );
+      errorClass: AddonStudyUpdateError,
+      reportError: this.reportUpdateError,
+    });
 
     // All done, report success to Telemetry
-    TelemetryEvents.sendEvent("update", "addon_study", name, {
+    TelemetryEvents.sendEvent("update", "addon_study", slug, {
       addonId: installedId,
       addonVersion: installedVersion,
+      branch: branch.slug,
     });
   }
 
   reportEnrollError(error) {
     if (error instanceof AddonStudyEnrollError) {
       // One of our known errors. Report it nicely to telemetry
       TelemetryEvents.sendEvent("enrollFailed", "addon_study", error.studyName, error.extra);
     } else {
@@ -493,16 +583,22 @@ class AddonStudyAction extends BaseActio
       throw new Error(`No study found for recipe ${recipeId}.`);
     }
     if (!study.active) {
       throw new Error(`Cannot stop study for recipe ${recipeId}; it is already inactive.`);
     }
 
     await AddonStudies.markAsEnded(study, reason);
 
-    const addon = await AddonManager.getAddonByID(study.addonId);
-    if (addon) {
-      await addon.uninstall();
-    } else {
-      this.log.warn(`Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}: it is not installed.`);
+    // Study branches may inidicate that no add-on should be installed, as a
+    // form of control branch. In that case, `study.addonId` will be null (as
+    // will the other add-on related fields). Only try to uninstall the add-on
+    // if we expect one should be installed.
+    if (study.addonId) {
+      const addon = await AddonManager.getAddonByID(study.addonId);
+      if (addon) {
+        await addon.uninstall();
+      } else {
+        this.log.warn(`Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}: it is not installed.`);
+      }
     }
   }
 }
--- a/toolkit/components/normandy/actions/schemas/index.js
+++ b/toolkit/components/normandy/actions/schemas/index.js
@@ -96,16 +96,76 @@ const ActionSchemas = {
       isEnrollmentPaused: {
         description: "If true, new users will not be enrolled in the study.",
         type: "boolean",
         default: false,
       },
     },
   },
 
+  "branched-addon-study": {
+    $schema: "http://json-schema.org/draft-04/schema#",
+    title: "Enroll a user in an add-on experiment, with managed branches",
+    type: "object",
+    required: [
+      "slug",
+      "userFacingName",
+      "userFacingDescription",
+      "branches",
+    ],
+    properties: {
+      slug: {
+        description: "Machine-readable identifier",
+        type: "string",
+        minLength: 1,
+      },
+      userFacingName: {
+        description: "User-facing name of the study",
+        type: "string",
+        minLength: 1,
+      },
+      userFacingDescription: {
+        description: "User-facing description of the study",
+        type: "string",
+        minLength: 1,
+      },
+      isEnrollmentPaused: {
+        description: "If true, new users will not be enrolled in the study.",
+        type: "boolean",
+        default: false,
+      },
+      "branches": {
+        description: "List of experimental branches",
+        type: "array",
+        minItems: 1,
+        items: {
+          type: "object",
+          required: ["slug", "ratio", "extensionApiId"],
+          properties: {
+            slug: {
+              description: "Unique identifier for this branch of the experiment.",
+              type: "string",
+              pattern: "^[A-Za-z0-9\\-_]+$",
+            },
+            ratio: {
+              description: "Ratio of users who should be grouped into this branch.",
+              type: "integer",
+              minimum: 1,
+            },
+            extensionApiId: {
+              description: "The record ID of the add-on uploaded to the Normandy server. May be null, in which case no add-on will be installed.",
+              type: ["number", "null"],
+              default: null,
+            },
+          },
+        },
+      },
+    },
+  },
+
   "show-heartbeat": {
     "$schema": "http://json-schema.org/draft-04/schema#",
     "title": "Show a Heartbeat survey.",
     "description": "This action shows a single survey.",
 
     "type": "object",
     "required": [
       "surveyId",
--- a/toolkit/components/normandy/content/about-studies/about-studies.js
+++ b/toolkit/components/normandy/content/about-studies/about-studies.js
@@ -148,25 +148,25 @@ class StudyList extends React.Component 
     inactiveStudies.sort((a, b) => b.sortDate - a.sortDate);
 
     return (
       r("div", {},
         r("h2", {}, translations.activeStudiesList),
         r("ul", { className: "study-list active-study-list" },
           activeStudies.map(study => (
             study.type === "addon"
-            ? r(AddonStudyListItem, { key: study.name, study, translations })
+            ? r(AddonStudyListItem, { key: study.slug, study, translations })
             : r(PreferenceStudyListItem, { key: study.name, study, translations })
           )),
         ),
         r("h2", {}, translations.completedStudiesList),
         r("ul", { className: "study-list inactive-study-list" },
           inactiveStudies.map(study => (
             study.type === "addon"
-            ? r(AddonStudyListItem, { key: study.name, study, translations })
+            ? r(AddonStudyListItem, { key: study.slug, study, translations })
             : r(PreferenceStudyListItem, { key: study.name, study, translations })
           )),
         ),
       )
     );
   }
 }
 StudyList.propTypes = {
@@ -190,29 +190,29 @@ class AddonStudyListItem extends React.C
     });
   }
 
   render() {
     const { study, translations } = this.props;
     return (
       r("li", {
         className: classnames("study addon-study", { disabled: !study.active }),
-        "data-study-name": study.name,
+        "data-study-slug": study.slug, // used to identify this row in tests
       },
         r("div", { className: "study-icon" },
-          study.name.replace(/-?add-?on-?/, "").replace(/-?study-?/, "").slice(0, 1)
+          study.userFacingName.replace(/-?add-?on-?/i, "").replace(/-?study-?/i, "").slice(0, 1)
         ),
         r("div", { className: "study-details" },
           r("div", { className: "study-header" },
-            r("span", { className: "study-name" }, study.name),
+            r("span", { className: "study-name" }, study.userFacingName),
             r("span", {}, "\u2022"), // &bullet;
             r("span", { className: "study-status" }, study.active ? translations.activeStatus : translations.completeStatus),
           ),
           r("div", { className: "study-description" },
-            study.description
+            study.userFacingDescription
           ),
         ),
         r("div", { className: "study-actions" },
           study.active &&
           r("button", { className: "remove-button", onClick: this.handleClickRemove },
             r("div", { className: "button-box" },
               translations.removeButton
             ),
@@ -220,19 +220,20 @@ class AddonStudyListItem extends React.C
         ),
       )
     );
   }
 }
 AddonStudyListItem.propTypes = {
   study: PropTypes.shape({
     recipeId: PropTypes.number.isRequired,
-    name: PropTypes.string.isRequired,
+    slug: PropTypes.string.isRequired,
+    userFacingName: PropTypes.string.isRequired,
     active: PropTypes.bool.isRequired,
-    description: PropTypes.string.isRequired,
+    userFacingDescription: PropTypes.string.isRequired,
   }).isRequired,
   translations: PropTypes.object.isRequired,
 };
 
 /**
  * Details about an individual preference study, with an option to end it if it is active.
  */
 class PreferenceStudyListItem extends React.Component {
@@ -272,17 +273,17 @@ class PreferenceStudyListItem extends Re
       description = translations.preferenceStudyDescription
             .replace(/%(?:1\$)?S/, sanitizedPreferenceName)
             .replace(/%(?:2\$)?S/, sanitizedPreferenceValue);
     }
 
     return (
       r("li", {
         className: classnames("study pref-study", { disabled: study.expired }),
-        "data-study-name": study.name,
+        "data-study-slug": study.name, // used to identify this row in tests
       },
         r("div", { className: "study-icon" },
           userFacingName,
         ),
         r("div", { className: "study-details" },
           r("div", { className: "study-header" },
             r("span", { className: "study-name" }, study.name),
             r("span", {}, "\u2022"), // &bullet;
--- a/toolkit/components/normandy/lib/ActionsManager.jsm
+++ b/toolkit/components/normandy/lib/ActionsManager.jsm
@@ -1,13 +1,14 @@
 const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 const {LogManager} = ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonStudyAction: "resource://normandy/actions/AddonStudyAction.jsm",
+  BranchedAddonStudyAction: "resource://normandy/actions/BranchedAddonStudyAction.jsm",
   ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.jsm",
   PreferenceExperimentAction: "resource://normandy/actions/PreferenceExperimentAction.jsm",
   PreferenceRollbackAction: "resource://normandy/actions/PreferenceRollbackAction.jsm",
   PreferenceRolloutAction: "resource://normandy/actions/PreferenceRolloutAction.jsm",
   ShowHeartbeatAction: "resource://normandy/actions/ShowHeartbeatAction.jsm",
   SinglePreferenceExperimentAction: "resource://normandy/actions/SinglePreferenceExperimentAction.jsm",
   Uptake: "resource://normandy/lib/Uptake.jsm",
 });
@@ -23,16 +24,17 @@ class ActionsManager {
   constructor() {
     this.finalized = false;
 
     const addonStudyAction = new AddonStudyAction();
     const singlePreferenceExperimentAction = new SinglePreferenceExperimentAction();
 
     this.localActions = {
       "addon-study": addonStudyAction,
+      "branched-addon-study": new BranchedAddonStudyAction(),
       "console-log": new ConsoleLogAction(),
       "opt-out-study": addonStudyAction, // Legacy name used for addon-study on Normandy server
       "multi-preference-experiment": new PreferenceExperimentAction(),
       // Historically, this name meant SinglePreferenceExperimentAction.
       "preference-experiment": singlePreferenceExperimentAction,
       "preference-rollback": new PreferenceRollbackAction(),
       "preference-rollout": new PreferenceRolloutAction(),
       "single-preference-experiment": singlePreferenceExperimentAction,
--- a/toolkit/components/normandy/lib/AddonStudies.jsm
+++ b/toolkit/components/normandy/lib/AddonStudies.jsm
@@ -3,68 +3,82 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 /**
  * @typedef {Object} Study
  * @property {Number} recipeId
  *   ID of the recipe that created the study. Used as the primary key of the
  *   study.
- * @property {string} name
- *   Name of the study
- * @property {string} description
+ * @property {Number} slug
+ *   String code used to identify the study for use in Telemetry and logging.
+ * @property {string} userFacingName
+ *   Name of the study to show to the user
+ * @property {string} userFacingDescription
  *   Description of the study and its intent.
+ * @property {string} branch
+ *   The branch the user is enrolled in
  * @property {boolean} active
  *   Is the study still running?
  * @property {string} addonId
  *   Add-on ID for this particular study.
  * @property {string} addonUrl
  *   URL that the study add-on was installed from.
+ * @property {string} addonVersion
+ *   Study add-on version number
  * @property {int} extensionApiId
  *   The ID used to look up the extension in Normandy's API.
  * @property {string} extensionHash
  *   The hash of the XPI file.
  * @property {string} extensionHashAlgorithm
  *   The algorithm used to hash the XPI file.
- * @property {string} addonVersion
- *   Study add-on version number
  * @property {string} studyStartDate
  *   Date when the study was started.
  * @property {Date} studyEndDate
  *   Date when the study was ended.
  */
 
 const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
 
 ChromeUtils.defineModuleGetter(this, "IndexedDB", "resource://gre/modules/IndexedDB.jsm");
 ChromeUtils.defineModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
 ChromeUtils.defineModuleGetter(
   this, "CleanupManager", "resource://normandy/lib/CleanupManager.jsm"
 );
 ChromeUtils.defineModuleGetter(this, "LogManager", "resource://normandy/lib/LogManager.jsm");
+ChromeUtils.defineModuleGetter(
+  this, "TelemetryEnvironment", "resource://gre/modules/TelemetryEnvironment.jsm"
+);
 ChromeUtils.defineModuleGetter(this, "TelemetryEvents", "resource://normandy/lib/TelemetryEvents.jsm");
 
 var EXPORTED_SYMBOLS = ["AddonStudies"];
 
 const DB_NAME = "shield";
 const STORE_NAME = "addon-studies";
+const VERSION_STORE_NAME = "addon-studies-version";
 const DB_OPTIONS = {
-  version: 1,
+  version: 2,
 };
 const STUDY_ENDED_TOPIC = "shield-study-ended";
 const log = LogManager.getLogger("addon-studies");
 
 /**
  * Create a new connection to the database.
  */
 function openDatabase() {
-  return IndexedDB.open(DB_NAME, DB_OPTIONS, db => {
-    db.createObjectStore(STORE_NAME, {
-      keyPath: "recipeId",
-    });
+  return IndexedDB.open(DB_NAME, DB_OPTIONS, async (db, event) => {
+    if (event.oldVersion < 1) {
+      db.createObjectStore(STORE_NAME, {
+        keyPath: "recipeId",
+      });
+    }
+
+    if (event.oldVersion < 2) {
+      db.createObjectStore(VERSION_STORE_NAME);
+    }
   });
 }
 
 /**
  * Cache the database connection so that it is shared among multiple operations.
  */
 let databasePromise;
 async function getDatabase() {
@@ -121,16 +135,18 @@ var AddonStudies = {
           const store = getStore(db, "readwrite");
           await Promise.all(oldStudies.map(study => store.add(study)));
         }
       };
     };
   },
 
   async init() {
+    await this.migrations();
+
     // If an active study's add-on has been removed since we last ran, stop the
     // study.
     const activeStudies = (await this.getAll()).filter(study => study.active);
     for (const study of activeStudies) {
       const addon = await AddonManager.getAddonByID(study.addonId);
       if (!addon) {
         await this.markAsEnded(study, "uninstalled-sideload");
       }
@@ -138,16 +154,58 @@ var AddonStudies = {
 
     // Listen for add-on uninstalls so we can stop the corresponding studies.
     AddonManager.addAddonListener(this);
     CleanupManager.addCleanupHandler(() => {
       AddonManager.removeAddonListener(this);
     });
   },
 
+  async migrations() {
+    const db = await getDatabase();
+    const oldVersion = await db.objectStore(VERSION_STORE_NAME, "readonly").get("version") || 0;
+
+    if (oldVersion < 2) {
+      log.debug(`Running data migrations from ${oldVersion} to 2`);
+      // this object store expires after the first await, so don't save it
+      const studies = await db.objectStore(STORE_NAME, "readonly").getAll();
+
+      const writePromises = [];
+      const objectStore = db.objectStore(STORE_NAME, "readwrite");
+
+      for (const study of studies) {
+        // use existing name as slug
+        if (!study.slug) {
+          study.slug = study.name;
+        }
+
+        // Rename `name` and `description` as `userFacingName` and `userFacingDescription`
+        if (study.name && !study.userFacingName) {
+          study.userFacingName = study.name;
+          delete study.name;
+        }
+        if (study.description && !study.userFacingDescription) {
+          study.userFacingDescription = study.description;
+          delete study.description;
+        }
+
+        // Specify that existing recipes don't have branches
+        if (!study.branch) {
+          study.branch = AddonStudies.NO_BRANCHES_MARKER;
+        }
+
+        writePromises.push(objectStore.put(study));
+      }
+
+      await Promise.all(writePromises);
+    }
+
+    await db.objectStore(VERSION_STORE_NAME, "readwrite").put("version", 2);
+  },
+
   /**
    * If a study add-on is uninstalled, mark the study as having ended.
    * @param {Addon} addon
    */
   async onUninstalled(addon) {
     const activeStudies = (await this.getAll()).filter(study => study.active);
     const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
     if (matchingStudy) {
@@ -179,23 +237,42 @@ var AddonStudies = {
    * @param {Number} recipeId
    * @return {Study}
    */
   async get(recipeId) {
     const db = await getDatabase();
     return getStore(db, "readonly").get(recipeId);
   },
 
+  FILTER_BRANCHED_ONLY: Symbol("FILTER_BRANCHED_ONLY"),
+  FILTER_NOT_BRANCHED: Symbol("FILTER_NOT_BRANCHED"),
+  FILTER_ALL: Symbol("FILTER_ALL"),
+
   /**
    * Fetch all studies in storage.
    * @return {Array<Study>}
    */
-  async getAll() {
+  async getAll({ branched = AddonStudies.FILTER_ALL } = {}) {
     const db = await getDatabase();
-    return getStore(db, "readonly").getAll();
+    let results = await getStore(db, "readonly").getAll();
+
+    if (branched == AddonStudies.FILTER_BRANCHED_ONLY) {
+      results = results.filter(study => study.branch != AddonStudies.NO_BRANCHES_MARKER);
+    } else if (branched == AddonStudies.FILTER_NOT_BRANCHED) {
+      results = results.filter(study => study.branch == AddonStudies.NO_BRANCHES_MARKER);
+    }
+    return results;
+  },
+
+  /**
+   * Fetch all studies in storage.
+   * @return {Array<Study>}
+   */
+  async getAllActive(options) {
+    return (await this.getAll(options)).filter(study => study.active);
   },
 
   /**
    * Add a study to storage.
    * @return {Promise<void, Error>} Resolves when the study is stored, or rejects with an error.
    */
   async add(study) {
     const db = await getDatabase();
@@ -222,32 +299,34 @@ var AddonStudies = {
   },
 
   /**
    * Mark a study object as having ended. Modifies the study in-place.
    * @param {IDBDatabase} db
    * @param {Study} study
    * @param {String} reason Why the study is ending.
    */
-  async markAsEnded(study, reason) {
+  async markAsEnded(study, reason = "unknown") {
     if (reason === "unknown") {
-      log.warn(`Study ${study.name} ending for unknown reason.`);
+      log.warn(`Study ${study.slug} ending for unknown reason.`);
     }
 
     study.active = false;
     study.studyEndDate = new Date();
     const db = await getDatabase();
     await getStore(db, "readwrite").put(study);
 
     Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC, `${study.recipeId}`);
-    TelemetryEvents.sendEvent("unenroll", "addon_study", study.name, {
-      addonId: study.addonId,
-      addonVersion: study.addonVersion,
+    TelemetryEvents.sendEvent("unenroll", "addon_study", study.slug, {
+      addonId: study.addonId || AddonStudies.NO_ADDON_MARKER,
+      addonVersion: study.addonVersion || AddonStudies.NO_ADDON_MARKER,
       reason,
+      branch: study.branch,
     });
+    TelemetryEnvironment.setExperimentInactive(study.slug);
 
     await this.onUnenroll(study.addonId, reason);
   },
 
   // Maps extension id -> Set(callbacks)
   _unenrollListeners: new Map(),
 
   /**
@@ -279,8 +358,11 @@ var AddonStudies = {
     if (callbacks) {
       for (let callback of callbacks) {
         promises.push(callback(reason));
       }
     }
     return Promise.all(promises);
   },
 };
+
+AddonStudies.NO_BRANCHES_MARKER = "__NO_BRANCHES__";
+AddonStudies.NO_ADDON_MARKER = "__NO_ADDON__";
rename from toolkit/components/normandy/test/browser/addons/normandydriver-1.0/manifest.json
rename to toolkit/components/normandy/test/browser/addons/normandydriver-a-1.0/manifest.json
--- a/toolkit/components/normandy/test/browser/addons/normandydriver-1.0/manifest.json
+++ b/toolkit/components/normandy/test/browser/addons/normandydriver-a-1.0/manifest.json
@@ -1,11 +1,11 @@
 {
     "manifest_version": 2,
-    "name": "normandy_fixture",
+    "name": "normandy_fixture_a",
     "version": "1.0",
-    "description": "Dummy test fixture that's a webextension",
+    "description": "Dummy test fixture that's a webextension, branch A",
     "applications": {
         "gecko": {
-            "id": "normandydriver@example.com"
+            "id": "normandydriver-a@example.com"
         }
     }
 }
rename from toolkit/components/normandy/test/browser/addons/normandydriver-2.0/manifest.json
rename to toolkit/components/normandy/test/browser/addons/normandydriver-a-2.0/manifest.json
--- a/toolkit/components/normandy/test/browser/addons/normandydriver-2.0/manifest.json
+++ b/toolkit/components/normandy/test/browser/addons/normandydriver-a-2.0/manifest.json
@@ -1,11 +1,11 @@
 {
     "manifest_version": 2,
-    "name": "normandy_fixture",
+    "name": "normandy_fixture_a",
     "version": "2.0",
-    "description": "Dummy test fixture that's a webextension",
+    "description": "Dummy test fixture that's a webextension, branch A",
     "applications": {
         "gecko": {
-            "id": "normandydriver@example.com"
+            "id": "normandydriver-a@example.com"
         }
     }
 }
copy from toolkit/components/normandy/test/browser/addons/normandydriver-1.0/manifest.json
copy to toolkit/components/normandy/test/browser/addons/normandydriver-b-1.0/manifest.json
--- a/toolkit/components/normandy/test/browser/addons/normandydriver-1.0/manifest.json
+++ b/toolkit/components/normandy/test/browser/addons/normandydriver-b-1.0/manifest.json
@@ -1,11 +1,11 @@
 {
     "manifest_version": 2,
-    "name": "normandy_fixture",
+    "name": "normandy_fixture_b",
     "version": "1.0",
-    "description": "Dummy test fixture that's a webextension",
+    "description": "Dummy test fixture that's a webextension, branch B",
     "applications": {
         "gecko": {
-            "id": "normandydriver@example.com"
+            "id": "normandydriver-b@example.com"
         }
     }
 }
--- a/toolkit/components/normandy/test/browser/browser.ini
+++ b/toolkit/components/normandy/test/browser/browser.ini
@@ -1,23 +1,26 @@
 [DEFAULT]
 tags = addons
 support-files =
   action_server.sjs
-  addons/normandydriver-1.0.xpi
-  addons/normandydriver-2.0.xpi
+  addons/normandydriver-a-1.0.xpi
+  addons/normandydriver-b-1.0.xpi
+  addons/normandydriver-a-2.0.xpi
 generated-files =
-  addons/normandydriver-1.0.xpi
-  addons/normandydriver-2.0.xpi
+  addons/normandydriver-a-1.0.xpi
+  addons/normandydriver-b-1.0.xpi
+  addons/normandydriver-a-2.0.xpi
 head = head.js
 [browser_about_preferences.js]
 # Skip this test when FHR/Telemetry aren't available.
 skip-if = !healthreport || !telemetry
 [browser_about_studies.js]
 [browser_actions_AddonStudyAction.js]
+[browser_actions_BranchedAddonStudyAction.js]
 [browser_actions_ConsoleLogAction.js]
 [browser_actions_PreferenceExperimentAction.js]
 [browser_actions_PreferenceRolloutAction.js]
 [browser_actions_PreferenceRollbackAction.js]
 [browser_actions_ShowHeartbeatAction.js]
 [browser_actions_SinglePreferenceExperimentAction.js]
 [browser_ActionsManager.js]
 [browser_AddonStudies.js]
--- a/toolkit/components/normandy/test/browser/browser_AddonStudies.js
+++ b/toolkit/components/normandy/test/browser/browser_AddonStudies.js
@@ -18,17 +18,17 @@ decorate_task(
       null,
       "get returns null when the requested study does not exist"
     );
   }
 );
 
 decorate_task(
   AddonStudies.withStudies([
-    addonStudyFactory({name: "test-study"}),
+    addonStudyFactory({slug: "test-study"}),
   ]),
   async function testGet([study]) {
     const storedStudy = await AddonStudies.get(study.recipeId);
     Assert.deepEqual(study, storedStudy, "get retrieved a study from storage.");
   }
 );
 
 decorate_task(
@@ -43,31 +43,31 @@ decorate_task(
       new Set(studies),
       "getAll returns every stored study.",
     );
   }
 );
 
 decorate_task(
   AddonStudies.withStudies([
-    addonStudyFactory({name: "test-study"}),
+    addonStudyFactory({slug: "test-study"}),
   ]),
   async function testHas([study]) {
     let hasStudy = await AddonStudies.has(study.recipeId);
     ok(hasStudy, "has returns true for a study that exists in storage.");
 
     hasStudy = await AddonStudies.has("does-not-exist");
     ok(!hasStudy, "has returns false for a study that doesn't exist in storage.");
   }
 );
 
 decorate_task(
   AddonStudies.withStudies([
-    addonStudyFactory({name: "test-study1"}),
-    addonStudyFactory({name: "test-study2"}),
+    addonStudyFactory({slug: "test-study1"}),
+    addonStudyFactory({slug: "test-study2"}),
   ]),
   async function testClear([study1, study2]) {
     const hasAll = (
       (await AddonStudies.has(study1.recipeId)) &&
       (await AddonStudies.has(study2.recipeId))
     );
     ok(hasAll, "Before calling clear, both studies are in storage.");
 
@@ -77,24 +77,24 @@ decorate_task(
       (await AddonStudies.has(study2.recipeId))
     );
     ok(!hasAny, "After calling clear, all studies are removed from storage.");
   }
 );
 
 decorate_task(
   AddonStudies.withStudies([
-    addonStudyFactory({name: "foo"}),
+    addonStudyFactory({slug: "foo"}),
   ]),
   async function testUpdate([study]) {
     Assert.deepEqual(await AddonStudies.get(study.recipeId), study);
 
     const updatedStudy = {
       ...study,
-      name: "bar",
+      slug: "bar",
     };
     await AddonStudies.update(updatedStudy);
 
     Assert.deepEqual(await AddonStudies.get(study.recipeId), updatedStudy);
   }
 );
 
 decorate_task(
@@ -113,20 +113,21 @@ decorate_task(
     ok(
       newActiveStudy.studyEndDate,
       "init sets the study end date if a study's add-on is not installed."
     );
     let events = Services.telemetry.snapshotEvents(Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, false);
     events = (events.parent || []).filter(e => e[1] == "normandy");
     Assert.deepEqual(
       events[0].slice(2), // strip timestamp and "normandy"
-      ["unenroll", "addon_study", activeUninstalledStudy.name, {
+      ["unenroll", "addon_study", activeUninstalledStudy.slug, {
         addonId: activeUninstalledStudy.addonId,
         addonVersion: activeUninstalledStudy.addonVersion,
         reason: "uninstalled-sideload",
+        branch: AddonStudies.NO_BRANCHES_MARKER,
       }],
       "AddonStudies.init() should send the correct telemetry event"
     );
 
     const newInactiveStudy = await AddonStudies.get(inactiveStudy.recipeId);
     is(
       newInactiveStudy.studyEndDate.getFullYear(),
       2012,
--- a/toolkit/components/normandy/test/browser/browser_about_studies.js
+++ b/toolkit/components/normandy/test/browser/browser_about_studies.js
@@ -74,31 +74,34 @@ decorate_task(
     BrowserTestUtils.removeTab(tab);
   }
 );
 
 // Test that the study listing shows studies in the proper order and grouping
 decorate_task(
   AddonStudies.withStudies([
     addonStudyFactory({
-      name: "A Fake Add-on Study",
+      slug: "fake-study-a",
+      userFacingName: "A Fake Add-on Study",
       active: true,
-      description: "A fake description",
+      userFacingDescription: "A fake description",
       studyStartDate: new Date(2018, 0, 4),
     }),
     addonStudyFactory({
-      name: "B Fake Add-on Study",
+      slug: "fake-study-b",
+      userFacingName: "B Fake Add-on Study",
       active: false,
-      description: "B fake description",
+      userFacingDescription: "B fake description",
       studyStartDate: new Date(2018, 0, 2),
     }),
     addonStudyFactory({
-      name: "C Fake Add-on Study",
+      slug: "fake-study-c",
+      userFacingName: "C Fake Add-on Study",
       active: true,
-      description: "C fake description",
+      userFacingDescription: "C fake description",
       studyStartDate: new Date(2018, 0, 1),
     }),
   ]),
   PreferenceExperiments.withMockExperiments([
     preferenceStudyFactory({
       name: "D Fake Preference Study",
       lastSeen: new Date(2018, 0, 3),
       expired: false,
@@ -114,40 +117,40 @@ decorate_task(
       expired: false,
     }),
   ]),
   withAboutStudies,
   async function testStudyListing(addonStudies, prefStudies, browser) {
     await ContentTask.spawn(browser, { addonStudies, prefStudies }, async ({ addonStudies, prefStudies }) => {
       const doc = content.document;
 
-      function getStudyRow(docElem, studyName) {
-        return docElem.querySelector(`.study[data-study-name="${studyName}"]`);
+      function getStudyRow(docElem, slug) {
+        return docElem.querySelector(`.study[data-study-slug="${slug}"]`);
       }
 
       await ContentTaskUtils.waitForCondition(() => doc.querySelectorAll(".active-study-list .study").length);
       const activeNames = Array.from(doc.querySelectorAll(".active-study-list .study"))
-        .map(row => row.dataset.studyName);
+        .map(row => row.dataset.studySlug);
       const inactiveNames = Array.from(doc.querySelectorAll(".inactive-study-list .study"))
-        .map(row => row.dataset.studyName);
+        .map(row => row.dataset.studySlug);
 
       Assert.deepEqual(
         activeNames,
-        [prefStudies[2].name, addonStudies[0].name, prefStudies[0].name, addonStudies[2].name],
+        [prefStudies[2].name, addonStudies[0].slug, prefStudies[0].name, addonStudies[2].slug],
         "Active studies are grouped by enabled status, and sorted by date",
       );
       Assert.deepEqual(
         inactiveNames,
-        [prefStudies[1].name, addonStudies[1].name],
+        [prefStudies[1].name, addonStudies[1].slug],
         "Inactive studies are grouped by enabled status, and sorted by date",
       );
 
-      const activeAddonStudy = getStudyRow(doc, addonStudies[0].name);
+      const activeAddonStudy = getStudyRow(doc, addonStudies[0].slug);
       ok(
-        activeAddonStudy.querySelector(".study-description").textContent.includes(addonStudies[0].description),
+        activeAddonStudy.querySelector(".study-description").textContent.includes(addonStudies[0].userFacingDescription),
         "Study descriptions are shown in about:studies."
       );
       is(
         activeAddonStudy.querySelector(".study-status").textContent,
         "Active",
         "Active studies show an 'Active' indicator."
       );
       ok(
@@ -155,17 +158,17 @@ decorate_task(
         "Active studies show a remove button"
       );
       is(
         activeAddonStudy.querySelector(".study-icon").textContent.toLowerCase(),
         "a",
         "Study icons use the first letter of the study name."
       );
 
-      const inactiveAddonStudy = getStudyRow(doc, addonStudies[1].name);
+      const inactiveAddonStudy = getStudyRow(doc, addonStudies[1].slug);
       is(
         inactiveAddonStudy.querySelector(".study-status").textContent,
         "Complete",
         "Inactive studies are marked as complete."
       );
       ok(
         !inactiveAddonStudy.querySelector(".remove-button"),
         "Inactive studies do not show a remove button"
@@ -200,20 +203,20 @@ decorate_task(
       );
       ok(
         !inactivePrefStudy.querySelector(".remove-button"),
         "Inactive studies do not show a remove button"
       );
 
       activeAddonStudy.querySelector(".remove-button").click();
       await ContentTaskUtils.waitForCondition(() => (
-        getStudyRow(doc, addonStudies[0].name).matches(".study.disabled")
+        getStudyRow(doc, addonStudies[0].slug).matches(".study.disabled")
       ));
       ok(
-        getStudyRow(doc, addonStudies[0].name).matches(".study.disabled"),
+        getStudyRow(doc, addonStudies[0].slug).matches(".study.disabled"),
         "Clicking the remove button updates the UI to show that the study has been disabled."
       );
 
       activePrefStudy.querySelector(".remove-button").click();
       await ContentTaskUtils.waitForCondition(() => (
         getStudyRow(doc, prefStudies[0].name).matches(".study.disabled")
       ));
       ok(
@@ -255,19 +258,20 @@ decorate_task(
   }
 );
 
 // Test that the message shown when studies are disabled and studies exist
 decorate_task(
   withAboutStudies,
   AddonStudies.withStudies([
     addonStudyFactory({
-      name: "A Fake Add-on Study",
+      userFacingName: "A Fake Add-on Study",
+      slug: "fake-addon-study",
       active: false,
-      description: "A fake description",
+      userFacingDescription: "A fake description",
       studyStartDate: new Date(2018, 0, 4),
     }),
   ]),
   PreferenceExperiments.withMockExperiments([
     preferenceStudyFactory({
       name: "B Fake Preference Study",
       lastSeen: new Date(2018, 0, 5),
       expired: true,
--- a/toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
@@ -1,176 +1,45 @@
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm", this);
 ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
 ChromeUtils.import("resource://normandy/actions/AddonStudyAction.jsm", this);
 ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
 ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
 
-const FIXTURE_ADDON_ID = "normandydriver@example.com";
-const FIXTURE_ADDON_BASE_URL = "http://example.com/browser/toolkit/components/normandy/test/browser/addons/";
-
-const FIXTURE_ADDONS = [
-  "normandydriver-1.0",
-  "normandydriver-2.0",
-];
-
-// Generate fixture add-on details
-const FIXTURE_ADDON_DETAILS = {};
-FIXTURE_ADDONS.forEach(addon => {
-  const filename = `${addon}.xpi`;
-  const dir = getChromeDir(getResolvedURI(gTestPath));
-  dir.append("addons");
-  dir.append(filename);
-  const xpiFile = Services.io.newFileURI(dir).QueryInterface(Ci.nsIFileURL).file;
-
-  FIXTURE_ADDON_DETAILS[addon] = {
-    url: `${FIXTURE_ADDON_BASE_URL}${filename}`,
-    hash: CryptoUtils.getFileHash(xpiFile, "sha256"),
-  };
-});
-
 function addonStudyRecipeFactory(overrides = {}) {
   let args = {
     name: "Fake name",
     description: "fake description",
     addonUrl: "https://example.com/study.xpi",
     extensionApiId: 1,
   };
   if (Object.hasOwnProperty.call(overrides, "arguments")) {
     args = Object.assign(args, overrides.arguments);
     delete overrides.arguments;
   }
   return recipeFactory(Object.assign({ action: "addon-study", arguments: args }, overrides));
 }
 
-function extensionDetailsFactory(overrides = {}) {
-  return Object.assign({
-    id: 1,
-    name: "Normandy Fixture",
-    xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
-    extension_id: FIXTURE_ADDON_ID,
-    version: "1.0",
-    hash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
-    hash_algorithm: "sha256",
-  }, overrides);
-}
-
-/**
- * Utility function to uninstall addons safely. Preventing the issue mentioned
- * in bug 1485569.
- *
- * addon.uninstall is async, but it also triggers the AddonStudies onUninstall
- * listener, which is not awaited. Wrap it here and trigger a promise once it's
- * done so we can wait until AddonStudies cleanup is finished.
- */
-async function safeUninstallAddon(addon) {
-  const activeStudies = (await AddonStudies.getAll()).filter(study => study.active);
-  const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
-
-  let studyEndedPromise;
-  if (matchingStudy) {
-    studyEndedPromise = TestUtils.topicObserved("shield-study-ended", (subject, message) => {
-      return message === `${matchingStudy.recipeId}`;
-    });
-  }
-
-  const addonUninstallPromise = addon.uninstall();
-
-  return Promise.all([studyEndedPromise, addonUninstallPromise]);
-}
-
-/**
- * Test decorator that is a modified version of the withInstalledWebExtension
- * decorator that safely uninstalls the created addon.
- */
-function withInstalledWebExtensionSafe(manifestOverrides = {}) {
-  return testFunction => {
-    return async function wrappedTestFunction(...args) {
-      const decorated = withInstalledWebExtension(manifestOverrides, true)(async ([id, file]) => {
-        try {
-          await testFunction(...args, [id, file]);
-        } finally {
-          let addon = await AddonManager.getAddonByID(id);
-          if (addon) {
-            await safeUninstallAddon(addon);
-            addon = await AddonManager.getAddonByID(id);
-            ok(!addon, "add-on should be uninstalled");
-          }
-        }
-      });
-      await decorated();
-    };
-  };
-}
-
-/**
- * Test decorator to provide a web extension installed from a URL.
- */
-function withInstalledWebExtensionFromURL(url) {
-  return function wrapper(testFunction) {
-    return async function wrappedTestFunction(...args) {
-      let startupPromise;
-      let addonId;
-
-      const install = await AddonManager.getInstallForURL(url);
-      const listener = {
-        onInstallStarted(cbInstall) {
-          addonId = cbInstall.addon.id;
-          startupPromise = AddonTestUtils.promiseWebExtensionStartup(addonId);
-        },
-      };
-      install.addListener(listener);
-
-      await install.install();
-      await startupPromise;
-
-      try {
-        await testFunction(...args, [addonId, url]);
-      } finally {
-        const addonToUninstall = await AddonManager.getAddonByID(addonId);
-        await safeUninstallAddon(addonToUninstall);
-      }
-    };
-  };
-}
-
-/**
- * Test decorator that checks that the test cleans up all add-ons installed
- * during the test. Likely needs to be the first decorator used.
- */
-function ensureAddonCleanup(testFunction) {
-  return async function wrappedTestFunction(...args) {
-    const beforeAddons = new Set(await AddonManager.getAllAddons());
-
-    try {
-      await testFunction(...args);
-    } finally {
-      const afterAddons = new Set(await AddonManager.getAllAddons());
-      Assert.deepEqual(beforeAddons, afterAddons, "The add-ons should be same before and after the test");
-    }
-  };
-}
-
 // Test that enroll is not called if recipe is already enrolled and update does nothing
 // if recipe is unchanged
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   AddonStudies.withStudies([addonStudyFactory()]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "1.0"}),
   async function enrollTwiceFail(mockApi, [study], sendEventStub, installedAddon) {
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
-        name: study.name,
-        description: study.description,
+        name: study.slug,
+        description: study.userFacingDescription,
         addonUrl: study.addonUrl,
         extensionApiId: study.extensionApiId,
       },
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         extension_id: study.addonId,
@@ -226,17 +95,17 @@ decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   withSendEventStub,
   AddonStudies.withStudies(),
   async function enrollHashCheckFails(mockApi, sendEventStub) {
     const recipe = addonStudyRecipeFactory({
       arguments: {
         extensionApiId: 1,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       },
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         hash: "badhash",
       }),
     };
@@ -261,25 +130,25 @@ decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   withSendEventStub,
   AddonStudies.withStudies(),
   async function enrollFailsMetadataMismatch(mockApi, sendEventStub) {
     const recipe = addonStudyRecipeFactory({
       arguments: {
         extensionApiId: 1,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       },
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         xpi: recipe.arguments.addonUrl,
         version: "1.5",
-        hash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
       }),
     };
     const action = new AddonStudyAction();
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
     const studies = await AddonStudies.getAll();
     Assert.deepEqual(studies, [], "the study should not be in the database");
@@ -296,17 +165,17 @@ decorate_task(
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   withSendEventStub,
   withInstalledWebExtensionSafe({ version: "0.1", id: FIXTURE_ADDON_ID }),
   AddonStudies.withStudies(),
   async function conflictingEnrollment(mockApi, sendEventStub, [installedAddonId, installedAddonFile]) {
     is(installedAddonId, FIXTURE_ADDON_ID, "Generated, installed add-on should have the same ID as the fixture");
-    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url;
+    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url;
     const recipe = addonStudyRecipeFactory({
       arguments: {
         name: "conflicting",
         extensionApiId: 1,
         addonUrl,
       },
     });
     mockApi.extensionDetails = {
@@ -327,22 +196,24 @@ decorate_task(
       [["enrollFailed", "addon_study", recipe.arguments.name, { reason: "conflicting-addon-id" }]]
     );
   },
 );
 
 // Test a successful enrollment
 decorate_task(
   ensureAddonCleanup,
+  withMockNormandyApi,
   withSendEventStub,
-  withMockNormandyApi,
   AddonStudies.withStudies(),
   async function successfulEnroll(mockApi, sendEventStub, studies) {
+    const initialAddonIds = (await AddonManager.getAllAddons()).map(addon => addon.id);
+
     const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID);
-    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url;
+    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url;
 
     let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon, null, "Before enroll, the add-on is not installed");
 
     const recipe = addonStudyRecipeFactory({
       arguments: {
         name: "success",
         extensionApiId: 1,
@@ -366,67 +237,74 @@ decorate_task(
     Assert.deepEqual(addon.installTelemetryInfo, {source: "internal"},
                      "Got the expected installTelemetryInfo");
 
     const study = await AddonStudies.get(recipe.id);
     Assert.deepEqual(
       study,
       {
         recipeId: recipe.id,
-        name: recipe.arguments.name,
-        description: recipe.arguments.description,
+        branch: AddonStudies.NO_BRANCHES_MARKER,
+        slug: recipe.arguments.name,
+        userFacingName: recipe.arguments.name,
+        userFacingDescription: recipe.arguments.description,
         addonId: FIXTURE_ADDON_ID,
         addonVersion: "1.0",
         addonUrl,
         active: true,
-        studyStartDate: study.studyStartDate,
+        studyStartDate: study.studyStartDate, // checked below
+        studyEndDate: null,
         extensionApiId: recipe.arguments.extensionApiId,
         extensionHash: extensionDetails.hash,
         extensionHashAlgorithm: extensionDetails.hash_algorithm,
       },
       "study data should be stored",
     );
     ok(study.studyStartDate, "a start date should be assigned");
     is(study.studyEndDate, null, "an end date should not be assigned");
 
     sendEventStub.assertEvents(
       [["enroll", "addon_study", recipe.arguments.name, { addonId: FIXTURE_ADDON_ID, addonVersion: "1.0" }]]
     );
 
     // cleanup
     await safeUninstallAddon(addon);
-    Assert.deepEqual(AddonManager.getAllAddons(), [], "add-on should be uninstalled.");
+    Assert.deepEqual(
+      (await AddonManager.getAllAddons()).map(addon => addon.id),
+      initialAddonIds,
+      "all test add-ons are removed",
+    );
   },
 );
 
 // Test a successful update
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   AddonStudies.withStudies([addonStudyFactory({
     addonId: FIXTURE_ADDON_ID,
-    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
     extensionHashAlgorithm: "sha256",
     addonVersion: "1.0",
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "1.0"}),
   async function successfulUpdate(mockApi, [study], sendEventStub, installedAddon) {
-    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url;
+    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url;
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
-        name: study.name,
-        description: study.description,
+        name: study.userFacingName,
+        description: study.userFacingDescription,
         extensionApiId: study.extensionApiId,
         addonUrl,
       },
     });
-    const hash = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash;
+    const hash = FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash;
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         extension_id: study.addonId,
         xpi: addonUrl,
         hash,
         version: "2.0",
       }),
@@ -437,16 +315,17 @@ decorate_task(
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(!enrollSpy.called, "enroll should not be called");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
       [["update", "addon_study", recipe.arguments.name, {
         addonId: FIXTURE_ADDON_ID,
         addonVersion: "2.0",
+        branch: AddonStudies.NO_BRANCHES_MARKER,
       }]],
     );
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(
       updatedStudy,
       {
         ...study,
@@ -475,27 +354,27 @@ decorate_task(
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "0.1"}),
   async function updateFailsAddonIdMismatch(mockApi, [study], sendEventStub, installedAddon) {
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
-        name: study.name,
-        description: study.description,
+        name: study.slug,
+        description: study.userFacingDescription,
         extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       },
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         extension_id: FIXTURE_ADDON_ID,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       }),
     };
     const action = new AddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
@@ -523,27 +402,27 @@ decorate_task(
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: "test@example.com", version: "0.1"}),
   async function updateFailsAddonDoesNotExist(mockApi, [study], sendEventStub, installedAddon) {
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
-        name: study.name,
-        description: study.description,
+        name: study.slug,
+        description: study.userFacingDescription,
         extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       },
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         extension_id: study.addonId,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       }),
     };
     const action = new AddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
@@ -575,18 +454,18 @@ decorate_task(
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "0.1"}),
   async function updateDownloadFailure(mockApi, [study], sendEventStub, installedAddon) {
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
-        name: study.name,
-        description: study.description,
+        name: study.slug,
+        description: study.userFacingDescription,
         extensionApiId: study.extensionApiId,
         addonUrl: "https://example.com/404.xpi",
       },
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         extension_id: study.addonId,
@@ -625,27 +504,27 @@ decorate_task(
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "0.1"}),
   async function updateFailsHashCheckFail(mockApi, [study], sendEventStub, installedAddon) {
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
-        name: study.name,
-        description: study.description,
+        name: study.slug,
+        description: study.userFacingDescription,
         extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       },
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         extension_id: study.addonId,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
         hash: "badhash",
       }),
     };
     const action = new AddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
@@ -676,27 +555,27 @@ decorate_task(
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "2.0"}),
   async function upgradeFailsNoDowngrades(mockApi, [study], sendEventStub, installedAddon) {
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
-        name: study.name,
-        description: study.description,
+        name: study.slug,
+        description: study.userFacingDescription,
         extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       },
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         extension_id: study.addonId,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
         version: "1.0",
       }),
     };
     const action = new AddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
@@ -715,40 +594,40 @@ decorate_task(
 );
 
 // Test update fails when there is a version mismatch with metadata
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   AddonStudies.withStudies([addonStudyFactory({
     addonId: FIXTURE_ADDON_ID,
-    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
     extensionHashAlgorithm: "sha256",
     addonVersion: "1.0",
   })]),
   withSendEventStub,
-  withInstalledWebExtensionFromURL(FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url),
+  withInstalledWebExtensionFromURL(FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url),
   async function upgradeFailsMetadataMismatchVersion(mockApi, [study], sendEventStub, installedAddon) {
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
-        name: study.name,
-        description: study.description,
+        name: study.slug,
+        description: study.userFacingDescription,
         extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
       },
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         extension_id: study.addonId,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
         version: "3.0",
-        hash: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash,
       }),
     };
     const action = new AddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
@@ -823,17 +702,17 @@ decorate_task(
     const newStudy = AddonStudies.get(study.recipeId);
     is(!newStudy, false, "stop should mark the study as inactive");
     ok(newStudy.studyEndDate !== null, "the study should have an end date");
 
     addon = await AddonManager.getAddonByID(addonId);
     is(addon, null, "the add-on should be uninstalled after unenrolling");
 
     sendEventStub.assertEvents(
-      [["unenroll", "addon_study", study.name, {
+      [["unenroll", "addon_study", study.slug, {
         addonId,
         addonVersion: study.addonVersion,
         reason: "test-reason",
       }]]
     );
   },
 );
 
@@ -847,17 +726,17 @@ decorate_task(
   async function unenrollMissingAddonTest([study], sendEventStub) {
     const action = new AddonStudyAction();
 
     SimpleTest.waitForExplicitFinish();
     SimpleTest.monitorConsole(() => SimpleTest.finish(), [{message: /could not uninstall addon/i}]);
     await action.unenroll(study.recipeId);
 
     sendEventStub.assertEvents(
-      [["unenroll", "addon_study", study.name, {
+      [["unenroll", "addon_study", study.slug, {
         addonId: study.addonId,
         addonVersion: study.addonVersion,
         reason: "unknown",
       }]]
     );
 
     SimpleTest.endMonitorConsole();
   },
@@ -894,63 +773,65 @@ decorate_task(
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   withSendEventStub,
   AddonStudies.withStudies(),
   async function testEnrollmentPaused(mockApi, sendEventStub) {
     const action = new AddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
+    const updateSpy = sinon.spy(action, "update");
     const recipe = addonStudyRecipeFactory({arguments: {isEnrollmentPaused: true}});
     const extensionDetails = extensionDetailsFactory({
       id: recipe.arguments.extensionApiId,
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetails,
     };
     await action.runRecipe(recipe);
     const addon = await AddonManager.getAddonByID(extensionDetails.extension_id);
     is(addon, null, "the add-on should not have been installed");
     await action.finalize();
     ok(enrollSpy.called, "enroll should be called");
+    ok(!updateSpy.called, "update should not be called");
     sendEventStub.assertEvents([]);
   },
 );
 
 // Test that the action updates paused recipes
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   AddonStudies.withStudies([addonStudyFactory({
     addonId: FIXTURE_ADDON_ID,
-    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
     extensionHashAlgorithm: "sha256",
     addonVersion: "1.0",
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "1.0"}),
   async function testUpdateEnrollmentPaused(mockApi, [study], sendEventStub, installedAddon) {
-    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url;
+    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url;
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
         isEnrollmentPaused: true,
-        name: study.name,
-        description: study.description,
+        name: study.slug,
+        description: study.userFacingDescription,
         extensionApiId: study.extensionApiId,
         addonUrl,
       },
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         extension_id: study.addonId,
         xpi: addonUrl,
-        hash: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash,
         version: "2.0",
       }),
     };
     const action = new AddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
@@ -968,71 +849,71 @@ decorate_task(
   },
 );
 
 // Test that update method works for legacy studies with no hash
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   AddonStudies.withStudies([addonStudyFactory({
-    addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+    addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
     addonId: FIXTURE_ADDON_ID,
     addonVersion: "1.0",
   })]),
   withSendEventStub,
-  withInstalledWebExtensionFromURL(FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url),
+  withInstalledWebExtensionFromURL(FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url),
   async function updateWorksLegacyStudy(mockApi, [study], sendEventStub, installedAddon) {
     delete study.extensionHash;
     delete study.extensionHashAlgorithm;
     await AddonStudies.update(study);
 
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
-        name: study.name,
-        description: study.description,
+        name: study.slug,
+        description: study.userFacingDescription,
         extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
       },
     });
 
-    const hash = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash;
+    const hash = FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash;
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
         extension_id: study.addonId,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
         hash,
         version: "2.0",
       }),
     };
 
     const action = new AddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
 
     is(action.lastError, null, "lastError should be null");
     ok(!enrollSpy.called, "enroll should not be called");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
-      [["update", "addon_study", "Test study", {
-        addonId: "normandydriver@example.com",
+      [["update", "addon_study", study.slug, {
+        addonId: "normandydriver-a@example.com",
         addonVersion: "2.0",
       }]],
     );
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(
       updatedStudy,
       {
         ...study,
         addonVersion: "2.0",
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
         extensionApiId: recipe.arguments.extensionApiId,
         extensionHash: hash,
         extensionHashAlgorithm: "sha256",
       },
       "study data should be updated",
     );
   },
 );
copy from toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
copy to toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js
--- a/toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js
@@ -1,188 +1,81 @@
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm", this);
 ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
-ChromeUtils.import("resource://normandy/actions/AddonStudyAction.jsm", this);
+ChromeUtils.import("resource://normandy/actions/BranchedAddonStudyAction.jsm", this);
 ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
 ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
 
-const FIXTURE_ADDON_ID = "normandydriver@example.com";
-const FIXTURE_ADDON_BASE_URL = "http://example.com/browser/toolkit/components/normandy/test/browser/addons/";
-
-const FIXTURE_ADDONS = [
-  "normandydriver-1.0",
-  "normandydriver-2.0",
-];
-
-// Generate fixture add-on details
-const FIXTURE_ADDON_DETAILS = {};
-FIXTURE_ADDONS.forEach(addon => {
-  const filename = `${addon}.xpi`;
-  const dir = getChromeDir(getResolvedURI(gTestPath));
-  dir.append("addons");
-  dir.append(filename);
-  const xpiFile = Services.io.newFileURI(dir).QueryInterface(Ci.nsIFileURL).file;
-
-  FIXTURE_ADDON_DETAILS[addon] = {
-    url: `${FIXTURE_ADDON_BASE_URL}${filename}`,
-    hash: CryptoUtils.getFileHash(xpiFile, "sha256"),
-  };
-});
-
-function addonStudyRecipeFactory(overrides = {}) {
+function branchedAddonStudyRecipeFactory(overrides = {}) {
   let args = {
-    name: "Fake name",
-    description: "fake description",
-    addonUrl: "https://example.com/study.xpi",
-    extensionApiId: 1,
+    slug: "fake-slug",
+    userFacingName: "Fake name",
+    userFacingDescription: "fake description",
+    branches: [
+      {
+        slug: "a",
+        ratio: 1,
+        extensionApiId: 1,
+      },
+    ],
   };
   if (Object.hasOwnProperty.call(overrides, "arguments")) {
     args = Object.assign(args, overrides.arguments);
     delete overrides.arguments;
   }
-  return recipeFactory(Object.assign({ action: "addon-study", arguments: args }, overrides));
-}
-
-function extensionDetailsFactory(overrides = {}) {
-  return Object.assign({
-    id: 1,
-    name: "Normandy Fixture",
-    xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
-    extension_id: FIXTURE_ADDON_ID,
-    version: "1.0",
-    hash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
-    hash_algorithm: "sha256",
-  }, overrides);
-}
-
-/**
- * Utility function to uninstall addons safely. Preventing the issue mentioned
- * in bug 1485569.
- *
- * addon.uninstall is async, but it also triggers the AddonStudies onUninstall
- * listener, which is not awaited. Wrap it here and trigger a promise once it's
- * done so we can wait until AddonStudies cleanup is finished.
- */
-async function safeUninstallAddon(addon) {
-  const activeStudies = (await AddonStudies.getAll()).filter(study => study.active);
-  const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
-
-  let studyEndedPromise;
-  if (matchingStudy) {
-    studyEndedPromise = TestUtils.topicObserved("shield-study-ended", (subject, message) => {
-      return message === `${matchingStudy.recipeId}`;
-    });
-  }
-
-  const addonUninstallPromise = addon.uninstall();
-
-  return Promise.all([studyEndedPromise, addonUninstallPromise]);
+  return recipeFactory(Object.assign({
+    action: "branched-addon-study",
+    arguments: args,
+  }, overrides));
 }
 
-/**
- * Test decorator that is a modified version of the withInstalledWebExtension
- * decorator that safely uninstalls the created addon.
- */
-function withInstalledWebExtensionSafe(manifestOverrides = {}) {
-  return testFunction => {
-    return async function wrappedTestFunction(...args) {
-      const decorated = withInstalledWebExtension(manifestOverrides, true)(async ([id, file]) => {
-        try {
-          await testFunction(...args, [id, file]);
-        } finally {
-          let addon = await AddonManager.getAddonByID(id);
-          if (addon) {
-            await safeUninstallAddon(addon);
-            addon = await AddonManager.getAddonByID(id);
-            ok(!addon, "add-on should be uninstalled");
-          }
-        }
-      });
-      await decorated();
-    };
+function recipeFromStudy(study, overrides = {}) {
+  let args = {
+    slug: study.slug,
+    userFacingName: study.userFacingName,
+    branches: [
+      {
+        slug: "a",
+        ratio: 1,
+        extensionApiId: study.extensionApiId,
+      },
+    ],
   };
-}
-
-/**
- * Test decorator to provide a web extension installed from a URL.
- */
-function withInstalledWebExtensionFromURL(url) {
-  return function wrapper(testFunction) {
-    return async function wrappedTestFunction(...args) {
-      let startupPromise;
-      let addonId;
 
-      const install = await AddonManager.getInstallForURL(url);
-      const listener = {
-        onInstallStarted(cbInstall) {
-          addonId = cbInstall.addon.id;
-          startupPromise = AddonTestUtils.promiseWebExtensionStartup(addonId);
-        },
-      };
-      install.addListener(listener);
-
-      await install.install();
-      await startupPromise;
+  if (Object.hasOwnProperty.call(overrides, "arguments")) {
+    args = Object.assign(args, overrides.arguments);
+    delete overrides.arguments;
+  }
 
-      try {
-        await testFunction(...args, [addonId, url]);
-      } finally {
-        const addonToUninstall = await AddonManager.getAddonByID(addonId);
-        await safeUninstallAddon(addonToUninstall);
-      }
-    };
-  };
-}
-
-/**
- * Test decorator that checks that the test cleans up all add-ons installed
- * during the test. Likely needs to be the first decorator used.
- */
-function ensureAddonCleanup(testFunction) {
-  return async function wrappedTestFunction(...args) {
-    const beforeAddons = new Set(await AddonManager.getAllAddons());
-
-    try {
-      await testFunction(...args);
-    } finally {
-      const afterAddons = new Set(await AddonManager.getAllAddons());
-      Assert.deepEqual(beforeAddons, afterAddons, "The add-ons should be same before and after the test");
-    }
-  };
+  return branchedAddonStudyRecipeFactory(Object.assign({
+    id: study.recipeId,
+    arguments: args,
+  }, overrides));
 }
 
 // Test that enroll is not called if recipe is already enrolled and update does nothing
 // if recipe is unchanged
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
-  AddonStudies.withStudies([addonStudyFactory()]),
+  AddonStudies.withStudies([branchedAddonStudyFactory()]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "1.0"}),
   async function enrollTwiceFail(mockApi, [study], sendEventStub, installedAddon) {
-    const recipe = recipeFactory({
-      id: study.recipeId,
-      type: "addon-study",
-      arguments: {
-        name: study.name,
-        description: study.description,
-        addonUrl: study.addonUrl,
-        extensionApiId: study.extensionApiId,
-      },
-    });
+    const recipe = recipeFromStudy(study);
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
+      [study.extensionApiId]: extensionDetailsFactory({
+        id: study.extensionApiId,
         extension_id: study.addonId,
         hash: study.extensionHash,
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(!enrollSpy.called, "enroll should not be called");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents([]);
   },
@@ -191,369 +84,271 @@ decorate_task(
 // Test that if the add-on fails to install, the database is cleaned up and the
 // error is correctly reported.
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   withSendEventStub,
   AddonStudies.withStudies(),
   async function enrollDownloadFail(mockApi, sendEventStub) {
-    const recipe = addonStudyRecipeFactory({
+    const recipe = branchedAddonStudyRecipeFactory({
       arguments: {
-        addonUrl: "https://example.com/404.xpi",
-        extensionApiId: 404,
+        branches: [{ slug: "missing", ratio: 1, extensionApiId: 404 }],
       },
     });
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
-        xpi: recipe.arguments.addonUrl,
+      [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.branches[0].extensionApiId,
+        xpi: "https://example.com/404.xpi",
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
     const studies = await AddonStudies.getAll();
     Assert.deepEqual(studies, [], "the study should not be in the database");
 
     sendEventStub.assertEvents(
-      [["enrollFailed", "addon_study", recipe.arguments.name, {reason: "download-failure", detail: "ERROR_NETWORK_FAILURE"}]]
+      [["enrollFailed", "addon_study", recipe.arguments.name, {
+        reason: "download-failure",
+        detail: "ERROR_NETWORK_FAILURE",
+        branch: "missing",
+      }]]
     );
   }
 );
 
 // Ensure that the database is clean and error correctly reported if hash check fails
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   withSendEventStub,
   AddonStudies.withStudies(),
   async function enrollHashCheckFails(mockApi, sendEventStub) {
-    const recipe = addonStudyRecipeFactory({
-      arguments: {
-        extensionApiId: 1,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
-      },
-    });
+    const recipe = branchedAddonStudyRecipeFactory();
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
+      [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.branches[0].extensionApiId,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
         hash: "badhash",
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
     const studies = await AddonStudies.getAll();
     Assert.deepEqual(studies, [], "the study should not be in the database");
 
     sendEventStub.assertEvents(
       [["enrollFailed", "addon_study", recipe.arguments.name, {
         reason: "download-failure",
         detail: "ERROR_INCORRECT_HASH",
+        branch: "a",
       }]],
     );
   }
 );
 
 // Ensure that the database is clean and error correctly reported if there is a metadata mismatch
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   withSendEventStub,
   AddonStudies.withStudies(),
   async function enrollFailsMetadataMismatch(mockApi, sendEventStub) {
-    const recipe = addonStudyRecipeFactory({
-      arguments: {
-        extensionApiId: 1,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
-      },
-    });
+    const recipe = branchedAddonStudyRecipeFactory();
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
-        xpi: recipe.arguments.addonUrl,
+      [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.branches[0].extensionApiId,
         version: "1.5",
-        hash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
     const studies = await AddonStudies.getAll();
     Assert.deepEqual(studies, [], "the study should not be in the database");
 
     sendEventStub.assertEvents(
       [["enrollFailed", "addon_study", recipe.arguments.name, {
         reason: "metadata-mismatch",
+        branch: "a",
       }]],
     );
   }
 );
 
 // Test that in the case of a study add-on conflicting with a non-study add-on, the study does not enroll
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   withSendEventStub,
   withInstalledWebExtensionSafe({ version: "0.1", id: FIXTURE_ADDON_ID }),
   AddonStudies.withStudies(),
   async function conflictingEnrollment(mockApi, sendEventStub, [installedAddonId, installedAddonFile]) {
     is(installedAddonId, FIXTURE_ADDON_ID, "Generated, installed add-on should have the same ID as the fixture");
-    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url;
-    const recipe = addonStudyRecipeFactory({
-      arguments: {
-        name: "conflicting",
-        extensionApiId: 1,
-        addonUrl,
-      },
-    });
+    const recipe = branchedAddonStudyRecipeFactory({ arguments: { slug: "conflicting" }});
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+      [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({
         id: recipe.arguments.extensionApiId,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon.version, "0.1", "The installed add-on should not be replaced");
 
     Assert.deepEqual(await AddonStudies.getAll(), [], "There should be no enrolled studies");
 
     sendEventStub.assertEvents(
-      [["enrollFailed", "addon_study", recipe.arguments.name, { reason: "conflicting-addon-id" }]]
+      [["enrollFailed", "addon_study", recipe.arguments.slug, { reason: "conflicting-addon-id" }]]
     );
   },
 );
 
-// Test a successful enrollment
-decorate_task(
-  ensureAddonCleanup,
-  withSendEventStub,
-  withMockNormandyApi,
-  AddonStudies.withStudies(),
-  async function successfulEnroll(mockApi, sendEventStub, studies) {
-    const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID);
-    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url;
-
-    let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
-    is(addon, null, "Before enroll, the add-on is not installed");
-
-    const recipe = addonStudyRecipeFactory({
-      arguments: {
-        name: "success",
-        extensionApiId: 1,
-        addonUrl,
-      },
-    });
-    const extensionDetails = extensionDetailsFactory({
-      id: recipe.arguments.extensionApiId,
-    });
-    mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetails,
-    };
-    const action = new AddonStudyAction();
-    await action.runRecipe(recipe);
-    is(action.lastError, null, "lastError should be null");
-
-    await webExtStartupPromise;
-    addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
-    ok(addon, "After start is called, the add-on is installed");
-
-    Assert.deepEqual(addon.installTelemetryInfo, {source: "internal"},
-                     "Got the expected installTelemetryInfo");
-
-    const study = await AddonStudies.get(recipe.id);
-    Assert.deepEqual(
-      study,
-      {
-        recipeId: recipe.id,
-        name: recipe.arguments.name,
-        description: recipe.arguments.description,
-        addonId: FIXTURE_ADDON_ID,
-        addonVersion: "1.0",
-        addonUrl,
-        active: true,
-        studyStartDate: study.studyStartDate,
-        extensionApiId: recipe.arguments.extensionApiId,
-        extensionHash: extensionDetails.hash,
-        extensionHashAlgorithm: extensionDetails.hash_algorithm,
-      },
-      "study data should be stored",
-    );
-    ok(study.studyStartDate, "a start date should be assigned");
-    is(study.studyEndDate, null, "an end date should not be assigned");
-
-    sendEventStub.assertEvents(
-      [["enroll", "addon_study", recipe.arguments.name, { addonId: FIXTURE_ADDON_ID, addonVersion: "1.0" }]]
-    );
-
-    // cleanup
-    await safeUninstallAddon(addon);
-    Assert.deepEqual(AddonManager.getAllAddons(), [], "add-on should be uninstalled.");
-  },
-);
-
 // Test a successful update
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
-  AddonStudies.withStudies([addonStudyFactory({
+  AddonStudies.withStudies([branchedAddonStudyFactory({
     addonId: FIXTURE_ADDON_ID,
-    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
     extensionHashAlgorithm: "sha256",
     addonVersion: "1.0",
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "1.0"}),
   async function successfulUpdate(mockApi, [study], sendEventStub, installedAddon) {
-    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url;
-    const recipe = recipeFactory({
-      id: study.recipeId,
-      type: "addon-study",
+    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url;
+    const recipe = recipeFromStudy(study, {
       arguments: {
-        name: study.name,
-        description: study.description,
-        extensionApiId: study.extensionApiId,
-        addonUrl,
+        branches: [{ slug: "a", extensionApiId: study.extensionApiId, ratio: 1 }],
       },
     });
-    const hash = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash;
+    const hash = FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash;
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
-        extension_id: study.addonId,
+      [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.branches[0].extensionApiId,
+        extension_id: FIXTURE_ADDON_ID,
         xpi: addonUrl,
         hash,
         version: "2.0",
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(!enrollSpy.called, "enroll should not be called");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
       [["update", "addon_study", recipe.arguments.name, {
         addonId: FIXTURE_ADDON_ID,
         addonVersion: "2.0",
+        branch: "a",
       }]],
     );
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(
       updatedStudy,
       {
         ...study,
         addonVersion: "2.0",
         addonUrl,
-        extensionApiId: recipe.arguments.extensionApiId,
+        extensionApiId: recipe.arguments.branches[0].extensionApiId,
         extensionHash: hash,
       },
       "study data should be updated",
     );
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     ok(addon.version === "2.0", "add-on should be updated");
   },
 );
 
 // Test update fails when addon ID does not match
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
-  AddonStudies.withStudies([addonStudyFactory({
+  AddonStudies.withStudies([branchedAddonStudyFactory({
     addonId: "test@example.com",
     extensionHash: "01d",
     extensionHashAlgorithm: "sha256",
     addonVersion: "0.1",
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "0.1"}),
   async function updateFailsAddonIdMismatch(mockApi, [study], sendEventStub, installedAddon) {
-    const recipe = recipeFactory({
-      id: study.recipeId,
-      type: "addon-study",
-      arguments: {
-        name: study.name,
-        description: study.description,
-        extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
-      },
-    });
+    const recipe = recipeFromStudy(study);
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
+      [study.extensionApiId]: extensionDetailsFactory({
+        id: study.extensionApiId,
         extension_id: FIXTURE_ADDON_ID,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
       [["updateFailed", "addon_study", recipe.arguments.name, {
         reason: "addon-id-mismatch",
+        branch: "a",
       }]],
     );
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     ok(addon.version === "0.1", "add-on should be unchanged");
   },
 );
 
 // Test update fails when original addon does not exist
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
-  AddonStudies.withStudies([addonStudyFactory({
+  AddonStudies.withStudies([branchedAddonStudyFactory({
     extensionHash: "01d",
     extensionHashAlgorithm: "sha256",
     addonVersion: "0.1",
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: "test@example.com", version: "0.1"}),
   async function updateFailsAddonDoesNotExist(mockApi, [study], sendEventStub, installedAddon) {
-    const recipe = recipeFactory({
-      id: study.recipeId,
-      type: "addon-study",
-      arguments: {
-        name: study.name,
-        description: study.description,
-        extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
-      },
-    });
+    const recipe = recipeFromStudy(study);
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
+      [study.extensionApiId]: extensionDetailsFactory({
+        id: study.extensionApiId,
         extension_id: study.addonId,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
       [["updateFailed", "addon_study", recipe.arguments.name, {
         reason: "addon-does-not-exist",
+        branch: "a",
       }]],
     );
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     ok(!addon, "new add-on should not be installed");
@@ -562,49 +357,41 @@ decorate_task(
     ok(addon, "old add-on should still be installed");
   },
 );
 
 // Test update fails when download fails
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
-  AddonStudies.withStudies([addonStudyFactory({
+  AddonStudies.withStudies([branchedAddonStudyFactory({
     addonId: FIXTURE_ADDON_ID,
     extensionHash: "01d",
     extensionHashAlgorithm: "sha256",
     addonVersion: "0.1",
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "0.1"}),
   async function updateDownloadFailure(mockApi, [study], sendEventStub, installedAddon) {
-    const recipe = recipeFactory({
-      id: study.recipeId,
-      type: "addon-study",
-      arguments: {
-        name: study.name,
-        description: study.description,
-        extensionApiId: study.extensionApiId,
-        addonUrl: "https://example.com/404.xpi",
-      },
-    });
+    const recipe = recipeFromStudy(study);
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
+      [study.extensionApiId]: extensionDetailsFactory({
+        id: study.extensionApiId,
         extension_id: study.addonId,
         xpi: "https://example.com/404.xpi",
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
       [["updateFailed", "addon_study", recipe.arguments.name, {
+        branch: "a",
         reason: "download-failure",
         detail: "ERROR_NETWORK_FAILURE",
       }]],
     );
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
@@ -612,50 +399,42 @@ decorate_task(
     ok(addon.version === "0.1", "add-on should be unchanged");
   },
 );
 
 // Test update fails when hash check fails
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
-  AddonStudies.withStudies([addonStudyFactory({
+  AddonStudies.withStudies([branchedAddonStudyFactory({
     addonId: FIXTURE_ADDON_ID,
     extensionHash: "01d",
     extensionHashAlgorithm: "sha256",
     addonVersion: "0.1",
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "0.1"}),
   async function updateFailsHashCheckFail(mockApi, [study], sendEventStub, installedAddon) {
-    const recipe = recipeFactory({
-      id: study.recipeId,
-      type: "addon-study",
-      arguments: {
-        name: study.name,
-        description: study.description,
-        extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
-      },
-    });
+    const recipe = recipeFromStudy(study);
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
+      [study.extensionApiId]: extensionDetailsFactory({
+        id: study.extensionApiId,
         extension_id: study.addonId,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
         hash: "badhash",
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
       [["updateFailed", "addon_study", recipe.arguments.name, {
+        branch: "a",
         reason: "download-failure",
         detail: "ERROR_INCORRECT_HASH",
       }]],
     );
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
@@ -663,101 +442,85 @@ decorate_task(
     ok(addon.version === "0.1", "add-on should be unchanged");
   },
 );
 
 // Test update fails on downgrade when study version is greater than extension version
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
-  AddonStudies.withStudies([addonStudyFactory({
+  AddonStudies.withStudies([branchedAddonStudyFactory({
     addonId: FIXTURE_ADDON_ID,
     extensionHash: "01d",
     extensionHashAlgorithm: "sha256",
     addonVersion: "2.0",
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "2.0"}),
   async function upgradeFailsNoDowngrades(mockApi, [study], sendEventStub, installedAddon) {
-    const recipe = recipeFactory({
-      id: study.recipeId,
-      type: "addon-study",
-      arguments: {
-        name: study.name,
-        description: study.description,
-        extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
-      },
-    });
+    const recipe = recipeFromStudy(study);
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
+      [study.extensionApiId]: extensionDetailsFactory({
+        id: study.extensionApiId,
         extension_id: study.addonId,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
         version: "1.0",
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
       [["updateFailed", "addon_study", recipe.arguments.name, {
         reason: "no-downgrade",
+        branch: "a",
       }]],
     );
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     ok(addon.version === "2.0", "add-on should be unchanged");
   },
 );
 
 // Test update fails when there is a version mismatch with metadata
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
-  AddonStudies.withStudies([addonStudyFactory({
+  AddonStudies.withStudies([branchedAddonStudyFactory({
     addonId: FIXTURE_ADDON_ID,
-    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
     extensionHashAlgorithm: "sha256",
     addonVersion: "1.0",
   })]),
   withSendEventStub,
-  withInstalledWebExtensionFromURL(FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url),
+  withInstalledWebExtensionFromURL(FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url),
   async function upgradeFailsMetadataMismatchVersion(mockApi, [study], sendEventStub, installedAddon) {
-    const recipe = recipeFactory({
-      id: study.recipeId,
-      type: "addon-study",
-      arguments: {
-        name: study.name,
-        description: study.description,
-        extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
-      },
-    });
+    const recipe = recipeFromStudy(study);
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
+      [study.extensionApiId]: extensionDetailsFactory({
+        id: study.extensionApiId,
         extension_id: study.addonId,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url,
         version: "3.0",
-        hash: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash,
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
       [["updateFailed", "addon_study", recipe.arguments.name, {
+        branch: "a",
         reason: "metadata-mismatch",
       }]],
     );
 
     const updatedStudy = await AddonStudies.get(recipe.id);
     Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
@@ -773,84 +536,87 @@ decorate_task(
   },
 );
 
 // Test that unenrolling fails if the study doesn't exist
 decorate_task(
   ensureAddonCleanup,
   AddonStudies.withStudies(),
   async function unenrollNonexistent(studies) {
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     await Assert.rejects(
       action.unenroll(42),
       /no study found/i,
       "unenroll should fail when no study exists"
     );
   }
 );
 
 // Test that unenrolling an inactive experiment fails
 decorate_task(
   ensureAddonCleanup,
   AddonStudies.withStudies([
-    addonStudyFactory({active: false}),
+    branchedAddonStudyFactory({active: false}),
   ]),
   withSendEventStub,
   async ([study], sendEventStub) => {
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     await Assert.rejects(
       action.unenroll(study.recipeId),
       /cannot stop study.*already inactive/i,
       "unenroll should fail when the requested study is inactive"
     );
   }
 );
 
 // test a successful unenrollment
 const testStopId = "testStop@example.com";
 decorate_task(
   ensureAddonCleanup,
   AddonStudies.withStudies([
-    addonStudyFactory({active: true, addonId: testStopId, studyEndDate: null}),
+    branchedAddonStudyFactory({active: true, addonId: testStopId, studyEndDate: null}),
   ]),
   withInstalledWebExtension({id: testStopId}, /* expectUninstall: */ true),
   withSendEventStub,
-  async function unenrollTest([study], [addonId, addonFile], sendEventStub) {
+  withStub(TelemetryEnvironment, "setExperimentInactive"),
+  async function unenrollTest([study], [addonId, addonFile], sendEventStub, setExperimentInactiveStub) {
     let addon = await AddonManager.getAddonByID(addonId);
     ok(addon, "the add-on should be installed before unenrolling");
 
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     await action.unenroll(study.recipeId, "test-reason");
 
     const newStudy = AddonStudies.get(study.recipeId);
     is(!newStudy, false, "stop should mark the study as inactive");
     ok(newStudy.studyEndDate !== null, "the study should have an end date");
 
     addon = await AddonManager.getAddonByID(addonId);
     is(addon, null, "the add-on should be uninstalled after unenrolling");
 
     sendEventStub.assertEvents(
       [["unenroll", "addon_study", study.name, {
         addonId,
         addonVersion: study.addonVersion,
         reason: "test-reason",
       }]]
     );
+
+    Assert.deepEqual(setExperimentInactiveStub.args, [[study.slug]], "setExperimentInactive should be called");
   },
 );
 
 // If the add-on for a study isn't installed, a warning should be logged, but the action is still successful
 decorate_task(
   ensureAddonCleanup,
   AddonStudies.withStudies([
-    addonStudyFactory({active: true, addonId: "missingAddon@example.com", studyEndDate: null}),
+    branchedAddonStudyFactory({active: true, addonId: "missingAddon@example.com"}),
   ]),
   withSendEventStub,
   async function unenrollMissingAddonTest([study], sendEventStub) {
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
 
     SimpleTest.waitForExplicitFinish();
     SimpleTest.monitorConsole(() => SimpleTest.finish(), [{message: /could not uninstall addon/i}]);
     await action.unenroll(study.recipeId);
 
     sendEventStub.assertEvents(
       [["unenroll", "addon_study", study.name, {
         addonId: study.addonId,
@@ -867,94 +633,86 @@ decorate_task(
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   withSendEventStub,
   withMockPreferences,
   AddonStudies.withStudies(),
   async function testOptOut(mockApi, sendEventStub, mockPreferences) {
     mockPreferences.set("app.shield.optoutstudies.enabled", false);
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
-    const recipe = addonStudyRecipeFactory();
+    const recipe = branchedAddonStudyRecipeFactory();
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
+      [recipe.arguments.branches[0].extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.branches[0].extensionApiId,
       }),
     };
     await action.runRecipe(recipe);
-    is(action.state, AddonStudyAction.STATE_DISABLED, "the action should be disabled");
+    is(action.state, BranchedAddonStudyAction.STATE_DISABLED, "the action should be disabled");
     await action.finalize();
-    is(action.state, AddonStudyAction.STATE_FINALIZED, "the action should be finalized");
+    is(action.state, BranchedAddonStudyAction.STATE_FINALIZED, "the action should be finalized");
     is(action.lastError, null, "lastError should be null");
     Assert.deepEqual(enrollSpy.args, [], "enroll should not be called");
     sendEventStub.assertEvents([]);
   },
 );
 
 // Test that the action does not enroll paused recipes
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
   withSendEventStub,
   AddonStudies.withStudies(),
   async function testEnrollmentPaused(mockApi, sendEventStub) {
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
-    const recipe = addonStudyRecipeFactory({arguments: {isEnrollmentPaused: true}});
+    const updateSpy = sinon.spy(action, "update");
+    const recipe = branchedAddonStudyRecipeFactory({arguments: {isEnrollmentPaused: true}});
     const extensionDetails = extensionDetailsFactory({
       id: recipe.arguments.extensionApiId,
     });
     mockApi.extensionDetails = {
       [recipe.arguments.extensionApiId]: extensionDetails,
     };
     await action.runRecipe(recipe);
     const addon = await AddonManager.getAddonByID(extensionDetails.extension_id);
     is(addon, null, "the add-on should not have been installed");
     await action.finalize();
+    ok(!updateSpy.called, "update should not be called");
     ok(enrollSpy.called, "enroll should be called");
     sendEventStub.assertEvents([]);
   },
 );
 
 // Test that the action updates paused recipes
 decorate_task(
   ensureAddonCleanup,
   withMockNormandyApi,
-  AddonStudies.withStudies([addonStudyFactory({
+  AddonStudies.withStudies([branchedAddonStudyFactory({
     addonId: FIXTURE_ADDON_ID,
-    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
     extensionHashAlgorithm: "sha256",
     addonVersion: "1.0",
   })]),
   withSendEventStub,
   withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "1.0"}),
   async function testUpdateEnrollmentPaused(mockApi, [study], sendEventStub, installedAddon) {
-    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url;
-    const recipe = recipeFactory({
-      id: study.recipeId,
-      type: "addon-study",
-      arguments: {
-        isEnrollmentPaused: true,
-        name: study.name,
-        description: study.description,
-        extensionApiId: study.extensionApiId,
-        addonUrl,
-      },
-    });
+    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].url;
+    const recipe = recipeFromStudy(study, { arguments: { isEnrollmentPaused: true }});
     mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
+      [study.extensionApiId]: extensionDetailsFactory({
+        id: study.extensionApiId,
         extension_id: study.addonId,
         xpi: addonUrl,
-        hash: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-2.0"].hash,
         version: "2.0",
       }),
     };
-    const action = new AddonStudyAction();
+    const action = new BranchedAddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
     const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
     ok(!enrollSpy.called, "enroll should not be called");
     ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents(
       [["update", "addon_study", recipe.arguments.name, {
@@ -963,89 +721,310 @@ decorate_task(
       }]],
     );
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     ok(addon.version === "2.0", "add-on should be updated");
   },
 );
 
-// Test that update method works for legacy studies with no hash
+// Test that unenroll called if the study is no longer sent from the server
 decorate_task(
   ensureAddonCleanup,
-  withMockNormandyApi,
-  AddonStudies.withStudies([addonStudyFactory({
-    addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
-    addonId: FIXTURE_ADDON_ID,
-    addonVersion: "1.0",
-  })]),
-  withSendEventStub,
-  withInstalledWebExtensionFromURL(FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url),
-  async function updateWorksLegacyStudy(mockApi, [study], sendEventStub, installedAddon) {
-    delete study.extensionHash;
-    delete study.extensionHashAlgorithm;
-    await AddonStudies.update(study);
-
-    const recipe = recipeFactory({
-      id: study.recipeId,
-      type: "addon-study",
-      arguments: {
-        name: study.name,
-        description: study.description,
-        extensionApiId: study.extensionApiId,
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
-      },
-    });
-
-    const hash = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash;
-    mockApi.extensionDetails = {
-      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
-        id: recipe.arguments.extensionApiId,
-        extension_id: study.addonId,
-        xpi: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
-        hash,
-        version: "2.0",
-      }),
-    };
-
-    const action = new AddonStudyAction();
-    const enrollSpy = sinon.spy(action, "enroll");
-    const updateSpy = sinon.spy(action, "update");
-    await action.runRecipe(recipe);
-
-    is(action.lastError, null, "lastError should be null");
-    ok(!enrollSpy.called, "enroll should not be called");
-    ok(updateSpy.called, "update should be called");
-    sendEventStub.assertEvents(
-      [["update", "addon_study", "Test study", {
-        addonId: "normandydriver@example.com",
-        addonVersion: "2.0",
-      }]],
-    );
-
-    const updatedStudy = await AddonStudies.get(recipe.id);
-    Assert.deepEqual(
-      updatedStudy,
-      {
-        ...study,
-        addonVersion: "2.0",
-        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
-        extensionApiId: recipe.arguments.extensionApiId,
-        extensionHash: hash,
-        extensionHashAlgorithm: "sha256",
-      },
-      "study data should be updated",
-    );
-  },
-);
-
-// Test that enroll is not called if recipe is already enrolled
-decorate_task(
-  ensureAddonCleanup,
-  AddonStudies.withStudies([addonStudyFactory()]),
-  async function enrollTwiceFail([study]) {
-    const action = new AddonStudyAction();
+  AddonStudies.withStudies([branchedAddonStudyFactory()]),
+  async function unenroll([study]) {
+    const action = new BranchedAddonStudyAction();
     const unenrollSpy = sinon.stub(action, "unenroll");
     await action.finalize();
     is(action.lastError, null, "lastError should be null");
     Assert.deepEqual(unenrollSpy.args, [[study.recipeId, "recipe-not-seen"]], "unenroll should be called");
   },
 );
+
+// A test function that will be parameterized over the argument "branch" below.
+// Mocks the branch selector, and then tests that the user correctly gets enrolled in that branch.
+const successEnrollBranchedTest = decorate(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  withStub(TelemetryEnvironment, "setExperimentActive"),
+  AddonStudies.withStudies(),
+  async function(branch, mockApi, sendEventStub, setExperimentActiveStub) {
+    ok(branch == "a" || branch == "b", "Branch should be either a or b");
+    const initialAddonIds = (await AddonManager.getAllAddons()).map(addon => addon.id);
+    const addonId = `normandydriver-${branch}@example.com`;
+    const otherBranchAddonId = `normandydriver-${branch == "a" ? "b" : "a"}`;
+    is(
+      await AddonManager.getAddonByID(addonId),
+      null,
+      "The add-on should not be installed at the beginning of the test",
+    );
+    is(
+      await AddonManager.getAddonByID(otherBranchAddonId),
+      null,
+      "The other branch's add-on should not be installed at the beginning of the test",
+    );
+
+    const recipe = branchedAddonStudyRecipeFactory({
+      arguments: {
+        slug: "success",
+        branches: [
+          { slug: "a", ratio: 1, extensionApiId: 1 },
+          { slug: "b", ratio: 1, extensionApiId: 2 },
+        ],
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.branches[0].extensionApiId]: {
+        id: recipe.arguments.branches[0].extensionApiId,
+        name: "Normandy Fixture A",
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+        extension_id: "normandydriver-a@example.com",
+        version: "1.0",
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+        hash_algorithm: "sha256",
+      },
+      [recipe.arguments.branches[1].extensionApiId]: {
+        id: recipe.arguments.branches[1].extensionApiId,
+        name: "Normandy Fixture B",
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].url,
+        extension_id: "normandydriver-b@example.com",
+        version: "1.0",
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-b-1.0"].hash,
+        hash_algorithm: "sha256",
+      },
+    };
+    const extensionApiId = recipe.arguments.branches[branch == "a" ? 0 : 1].extensionApiId;
+    const extensionDetails = mockApi.extensionDetails[extensionApiId];
+
+    const action = new BranchedAddonStudyAction();
+    const chooseBranchStub = sinon.stub(action, "chooseBranch");
+    chooseBranchStub.callsFake(async ({ branches }) => branches.find(b => b.slug === branch));
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    sendEventStub.assertEvents([
+      [
+        "enroll", "addon_study", recipe.arguments.slug, {
+          addonId,
+          addonVersion: "1.0",
+          branch,
+        },
+      ],
+    ]);
+
+    Assert.deepEqual(
+      setExperimentActiveStub.args,
+      [[recipe.arguments.slug, branch, {type: "normandy-addonstudy"}]],
+      "setExperimentActive should be called",
+    );
+
+    const addon = await AddonManager.getAddonByID(addonId);
+    ok(addon, "The chosen branch's add-on should be installed");
+    is(await AddonManager.getAddonByID(otherBranchAddonId), null, "The other branch's add-on should not be installed");
+
+    const study = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(
+      study,
+      {
+        recipeId: recipe.id,
+        slug: recipe.arguments.slug,
+        userFacingName: recipe.arguments.userFacingName,
+        userFacingDescription: recipe.arguments.userFacingDescription,
+        addonId,
+        addonVersion: "1.0",
+        addonUrl: FIXTURE_ADDON_DETAILS[`normandydriver-${branch}-1.0`].url,
+        active: true,
+        branch,
+        studyStartDate: study.studyStartDate, // This is checked below
+        studyEndDate: null,
+        extensionApiId: extensionDetails.id,
+        extensionHash: extensionDetails.hash,
+        extensionHashAlgorithm: extensionDetails.hash_algorithm,
+      },
+      "the correct study data should be stored",
+    );
+
+    // cleanup
+    await safeUninstallAddon(addon);
+    Assert.deepEqual(
+      (await AddonManager.getAllAddons()).map(addon => addon.id),
+      initialAddonIds,
+      "all test add-ons are removed",
+    );
+  }
+);
+
+add_task(() => successEnrollBranchedTest("a"));
+add_task(() => successEnrollBranchedTest("b"));
+
+// If the enrolled branch no longer exists, unenroll
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  AddonStudies.withStudies([branchedAddonStudyFactory()]),
+  withSendEventStub,
+  withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "1.0"}, /* expectUninstall: */ true),
+  async function unenrollIfBranchDisappears(mockApi, [study], sendEventStub, [addonId, addonFile]) {
+    const recipe = recipeFromStudy(study, {
+      arguments: {
+        branches: [
+          {
+            slug: "b",  // different from enrolled study
+            ratio: 1,
+            extensionApiId: study.extensionApiId,
+          },
+        ],
+      },
+    });
+    mockApi.extensionDetails = {
+      [study.extensionApiId]: extensionDetailsFactory({
+        id: study.extensionApiId,
+        extension_id: study.addonId,
+        hash: study.extensionHash,
+      }),
+    };
+    const action = new BranchedAddonStudyAction();
+    const enrollSpy = sinon.spy(action, "enroll");
+    const unenrollSpy = sinon.spy(action, "unenroll");
+    const updateSpy = sinon.spy(action, "update");
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    ok(!enrollSpy.called, "Enroll should not be called");
+    ok(updateSpy.called, "Update should be called");
+    ok(unenrollSpy.called, "Unenroll should be called");
+
+    sendEventStub.assertEvents(
+      [["unenroll", "addon_study", study.name, {
+        addonId,
+        addonVersion: study.addonVersion,
+        reason: "branch-removed",
+        branch: "a", // the original study branch
+      }]]
+    );
+
+    is(await AddonManager.getAddonByID(addonId), null, "the add-on should be uninstalled");
+
+    const storedStudy = await AddonStudies.get(recipe.id);
+    ok(!storedStudy.active, "Study should be inactive");
+    ok(storedStudy.branch == "a", "Study's branch should not change");
+    ok(storedStudy.studyEndDate, "Study's end date should be set");
+  },
+);
+
+// Test that branches without an add-on can be enrolled and unenrolled succesfully.
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  AddonStudies.withStudies(),
+  async function noAddonBranches(mockApi, sendEventStub) {
+    const initialAddonIds = (await AddonManager.getAllAddons()).map(addon => addon.id);
+
+    const recipe = branchedAddonStudyRecipeFactory({
+      arguments: {
+        slug: "no-op-branch",
+        branches: [
+          { slug: "a", ratio: 1, extensionApiId: null },
+        ],
+      },
+    });
+
+    let action = new BranchedAddonStudyAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+    is(action.lastError, null, "lastError should be null");
+
+    sendEventStub.assertEvents([
+      [
+        "enroll", "addon_study", recipe.arguments.name, {
+          addonId: AddonStudies.NO_ADDON_MARKER,
+          addonVersion: AddonStudies.NO_ADDON_MARKER,
+          branch: "a",
+        },
+      ],
+    ]);
+
+    Assert.deepEqual(
+      (await AddonManager.getAllAddons()).map(addon => addon.id),
+      initialAddonIds,
+      "No add-on should be installed for the study",
+    );
+
+    let study = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(
+      study,
+      {
+        recipeId: recipe.id,
+        slug: recipe.arguments.slug,
+        userFacingName: recipe.arguments.userFacingName,
+        userFacingDescription: recipe.arguments.userFacingDescription,
+        addonId: null,
+        addonVersion: null,
+        addonUrl: null,
+        active: true,
+        branch: "a",
+        studyStartDate: study.studyStartDate, // This is checked below
+        studyEndDate: null,
+        extensionApiId: null,
+        extensionHash: null,
+        extensionHashAlgorithm: null,
+      },
+      "the correct study data should be stored",
+    );
+    ok(study.studyStartDate, "studyStartDate should have a value");
+
+    // Now unenroll
+    action = new BranchedAddonStudyAction();
+    await action.finalize();
+    is(action.lastError, null, "lastError should be null");
+
+    sendEventStub.assertEvents([
+      // The event from before
+      [
+        "enroll", "addon_study", recipe.arguments.name, {
+          addonId: AddonStudies.NO_ADDON_MARKER,
+          addonVersion: AddonStudies.NO_ADDON_MARKER,
+          branch: "a",
+        },
+      ],
+      // And a new unenroll event
+      [
+        "unenroll", "addon_study", recipe.arguments.name, {
+          addonId: AddonStudies.NO_ADDON_MARKER,
+          addonVersion: AddonStudies.NO_ADDON_MARKER,
+          branch: "a",
+        },
+      ],
+    ]);
+
+    Assert.deepEqual(
+      (await AddonManager.getAllAddons()).map(addon => addon.id),
+      initialAddonIds,
+      "The set of add-ons should not change",
+    );
+
+    study = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(
+      study,
+      {
+        recipeId: recipe.id,
+        slug: recipe.arguments.slug,
+        userFacingName: recipe.arguments.userFacingName,
+        userFacingDescription: recipe.arguments.userFacingDescription,
+        addonId: null,
+        addonVersion: null,
+        addonUrl: null,
+        active: false,
+        branch: "a",
+        studyStartDate: study.studyStartDate, // This is checked below
+        studyEndDate: study.studyEndDate, // This is checked below
+        extensionApiId: null,
+        extensionHash: null,
+        extensionHashAlgorithm: null,
+      },
+      "the correct study data should be stored",
+    );
+    ok(study.studyStartDate, "studyStartDate should have a value");
+    ok(study.studyEndDate, "studyEndDate should have a value");
+  }
+);
--- a/toolkit/components/normandy/test/browser/head.js
+++ b/toolkit/components/normandy/test/browser/head.js
@@ -1,12 +1,13 @@
 ChromeUtils.import("resource://gre/modules/Preferences.jsm", this);
 ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm", this);
 ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
 ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this);
+ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
 ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
 ChromeUtils.defineModuleGetter(this, "TelemetryTestUtils",
                                "resource://testing-common/TelemetryTestUtils.jsm");
 
 const CryptoHash = Components.Constructor("@mozilla.org/security/hash;1",
                                           "nsICryptoHash", "initWithString");
 const FileInputStream = Components.Constructor("@mozilla.org/network/file-input-stream;1",
@@ -22,17 +23,17 @@ sinon.assert.fail = function(message) {
 // Prep Telemetry to receive events from tests
 TelemetryEvents.init();
 
 this.UUID_REGEX = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
 
 this.TEST_XPI_URL = (function() {
   const dir = getChromeDir(getResolvedURI(gTestPath));
   dir.append("addons");
-  dir.append("normandydriver-1.0.xpi");
+  dir.append("normandydriver-a-1.0.xpi");
   return Services.io.newFileURI(dir).spec;
 })();
 
 this.withWebExtension = function(manifestOverrides = {}) {
   return function wrapper(testFunction) {
     return async function wrappedTestFunction(...args) {
       const random = Math.random().toString(36).replace(/0./, "").substr(-3);
       let id = `normandydriver_${random}@example.com`;
@@ -110,17 +111,17 @@ this.withMockNormandyApi = function(test
         if (!details) {
           throw new Error(`Missing extension details for ${extensionId}`);
         }
         return details;
       }
     );
 
     try {
-      await testFunction(mockApi, ...args);
+      await testFunction(...args, mockApi);
     } finally {
       mockApi.fetchRecipes.restore();
       mockApi.fetchExtensionDetails.restore();
     }
   };
 };
 
 const preferenceBranches = {
@@ -249,32 +250,47 @@ this.decorate = function(...args) {
  *     }
  *   );
  */
 this.decorate_task = function(...args) {
   return add_task(decorate(...args));
 };
 
 let _addonStudyFactoryId = 0;
-this.addonStudyFactory = function(attrs) {
+this.addonStudyFactory = function(attrs = {}) {
+  for (const key of ["name", "description"]) {
+    if (attrs[key]) {
+      throw new Error(`${key} is no longer a valid key for addon studies, please update to v2 study schema`);
+    }
+  }
+
   return Object.assign({
     recipeId: _addonStudyFactoryId++,
-    name: "Test study",
-    description: "fake",
+    slug: "test-study",
+    userFacingName: "Test study",
+    userFacingDescription: "test description",
+    branch: AddonStudies.NO_BRANCHES_MARKER,
     active: true,
-    addonId: "fake@example.com",
+    addonId: FIXTURE_ADDON_ID,
     addonUrl: "http://test/addon.xpi",
     addonVersion: "1.0.0",
     studyStartDate: new Date(),
+    studyEndDate: null,
     extensionApiId: 1,
     extensionHash: "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171",
     extensionHashAlgorithm: "sha256",
   }, attrs);
 };
 
+this.branchedAddonStudyFactory = function(attrs) {
+  return this.addonStudyFactory(Object.assign({
+    branch: "a",
+  }, attrs));
+};
+
 let _preferenceStudyFactoryId = 0;
 this.preferenceStudyFactory = function(attrs) {
   const defaultPref = {
     "test.study": {},
   };
   const defaultPrefInfo = {
     preferenceValue: false,
     preferenceType: "boolean",
@@ -387,8 +403,141 @@ this.CryptoUtils = {
     const crypto = CryptoHash(algorithm);
     const fis = new FileInputStream(file, -1, -1, false);
     crypto.updateFromStream(fis, file.fileSize);
     const hash = this._getHashStringForCrypto(crypto);
     fis.close();
     return hash;
   },
 };
+
+const FIXTURE_ADDON_ID = "normandydriver-a@example.com";
+const FIXTURE_ADDON_BASE_URL = getRootDirectory(gTestPath)
+  .replace("chrome://mochitests/content", "http://example.com") + "/addons/";
+
+const FIXTURE_ADDONS = [
+  "normandydriver-a-1.0",
+  "normandydriver-b-1.0",
+  "normandydriver-a-2.0",
+];
+
+// Generate fixture add-on details
+this.FIXTURE_ADDON_DETAILS = {};
+FIXTURE_ADDONS.forEach(addon => {
+  const filename = `${addon}.xpi`;
+  const dir = getChromeDir(getResolvedURI(gTestPath));
+  dir.append("addons");
+  dir.append(filename);
+  const xpiFile = Services.io.newFileURI(dir).QueryInterface(Ci.nsIFileURL).file;
+
+  FIXTURE_ADDON_DETAILS[addon] = {
+    url: `${FIXTURE_ADDON_BASE_URL}${filename}`,
+    hash: CryptoUtils.getFileHash(xpiFile, "sha256"),
+  };
+});
+
+this.extensionDetailsFactory = function(overrides = {}) {
+  return Object.assign({
+    id: 1,
+    name: "Normandy Fixture",
+    xpi: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].url,
+    extension_id: FIXTURE_ADDON_ID,
+    version: "1.0",
+    hash: FIXTURE_ADDON_DETAILS["normandydriver-a-1.0"].hash,
+    hash_algorithm: "sha256",
+  }, overrides);
+};
+
+/**
+ * Utility function to uninstall addons safely. Preventing the issue mentioned
+ * in bug 1485569.
+ *
+ * addon.uninstall is async, but it also triggers the AddonStudies onUninstall
+ * listener, which is not awaited. Wrap it here and trigger a promise once it's
+ * done so we can wait until AddonStudies cleanup is finished.
+ */
+this.safeUninstallAddon = async function(addon) {
+  const activeStudies = (await AddonStudies.getAll()).filter(study => study.active);
+  const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
+
+  let studyEndedPromise;
+  if (matchingStudy) {
+    studyEndedPromise = TestUtils.topicObserved("shield-study-ended", (subject, message) => {
+      return message === `${matchingStudy.recipeId}`;
+    });
+  }
+
+  const addonUninstallPromise = addon.uninstall();
+
+  return Promise.all([studyEndedPromise, addonUninstallPromise]);
+};
+
+/**
+ * Test decorator that is a modified version of the withInstalledWebExtension
+ * decorator that safely uninstalls the created addon.
+ */
+this.withInstalledWebExtensionSafe = function(manifestOverrides = {}) {
+  return testFunction => {
+    return async function wrappedTestFunction(...args) {
+      const decorated = withInstalledWebExtension(manifestOverrides, true)(async ([id, file]) => {
+        try {
+          await testFunction(...args, [id, file]);
+        } finally {
+          let addon = await AddonManager.getAddonByID(id);
+          if (addon) {
+            await safeUninstallAddon(addon);
+            addon = await AddonManager.getAddonByID(id);
+            ok(!addon, "add-on should be uninstalled");
+          }
+        }
+      });
+      await decorated();
+    };
+  };
+};
+
+/**
+ * Test decorator to provide a web extension installed from a URL.
+ */
+this.withInstalledWebExtensionFromURL = function(url) {
+  return function wrapper(testFunction) {
+    return async function wrappedTestFunction(...args) {
+      let startupPromise;
+      let addonId;
+
+      const install = await AddonManager.getInstallForURL(url);
+      const listener = {
+        onInstallStarted(cbInstall) {
+          addonId = cbInstall.addon.id;
+          startupPromise = AddonTestUtils.promiseWebExtensionStartup(addonId);
+        },
+      };
+      install.addListener(listener);
+
+      await install.install();
+      await startupPromise;
+
+      try {
+        await testFunction(...args, [addonId, url]);
+      } finally {
+        const addonToUninstall = await AddonManager.getAddonByID(addonId);
+        await safeUninstallAddon(addonToUninstall);
+      }
+    };
+  };
+};
+
+/**
+ * Test decorator that checks that the test cleans up all add-ons installed
+ * during the test. Likely needs to be the first decorator used.
+ */
+this.ensureAddonCleanup = function(testFunction) {
+  return async function wrappedTestFunction(...args) {
+    const beforeAddons = new Set(await AddonManager.getAllAddons());
+
+    try {
+      await testFunction(...args);
+    } finally {
+      const afterAddons = new Set(await AddonManager.getAllAddons());
+      Assert.deepEqual(beforeAddons, afterAddons, "The add-ons should be same before and after the test");
+    }
+  };
+};
--- a/toolkit/components/normandy/test/browser/moz.build
+++ b/toolkit/components/normandy/test/browser/moz.build
@@ -2,18 +2,19 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 BROWSER_CHROME_MANIFESTS += [
   'browser.ini',
 ]
 
 addons = [
-  'normandydriver-1.0',
-  'normandydriver-2.0',
+  'normandydriver-a-1.0',
+  'normandydriver-b-1.0',
+  'normandydriver-a-2.0',
 ]
 
 output_dir = OBJDIR_FILES._tests.testing.mochitest.browser.toolkit.components.normandy.test.browser.addons
 
 for addon in addons:
     indir = 'addons/%s' % addon
     xpi = '%s.xpi' % indir
 
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -265,17 +265,17 @@ normandy:
   enroll:
     objects: ["preference_study", "addon_study", "preference_rollout"]
     description: >
       Sent when applying a Normandy recipe of the above types has succeeded.
     extra_keys:
       experimentType: >
         For preference_study recipes, the type of experiment this is ("exp" or "exp-highpop").
       branch: >
-        For preference_study recipes, the slug of the branch that was chosen for this client.
+        The slug of the branch that was chosen for this client.
       addonId: For addon_study recipes, the ID of the addon that was installed.
       addonVersion: For addon_study recipes, the version of the addon that was installed.
     bug_numbers: [1443560]
     notification_emails: ["normandy-notifications@mozilla.com"]
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
@@ -285,18 +285,19 @@ normandy:
     description: >
       Sent when applying a Normandy recipe of the above types has failed.
     extra_keys:
       reason: An error code describing the failure.
       preference: >
         For preference_rollout when reason=conflict, the name of the preference
         that was going to be modified.
       detail: >
-        Extra text describing the failure.
-        Currently only provided for addon_study.
+        For addon_study and branched_addon study, extra text describing the failure.
+      branch: >
+        The branch that failed to enroll.
     bug_numbers: [1443560]
     notification_emails: ["normandy-notifications@mozilla.com"]
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   update:
     objects: ["addon_study", "preference_rollout"]
@@ -306,33 +307,34 @@ normandy:
       recipe is being applied over an existing, older version previously
       fetched from the server.
     extra_keys:
       previousState: >
         For preference_rollout recipes, the state of the rollout that had been applied
         previously.
       addonId: For addon_study recipes, the ID of the addon that was updated.
       addonVersion: For addon_study recipes, the version of the addon that was installed.
+      branch: The branch that was updated.
     bug_numbers: [1443560, 1474413]
     notification_emails: ["normandy-notifications@mozilla.com"]
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   update_failed:
     methods: ["updateFailed"]
     objects: ["addon_study"]
     description: >
       Sent when applying a new version of a Normandy recipe of the above types (over an
       existing, older version previously fetched from the server) has failed.
     extra_keys:
       reason: An error code describing the failure.
       detail: >
-        Extra text describing the failure.
-        Currently only provided for addon_study.
+        Extra text describing the failure. Currently only provided for addon_study.
+      branch: The branch that failed to update.
     bug_numbers: [1474413]
     notification_emails: ["normandy-notifications@mozilla.com"]
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   unenroll:
     objects: ["preference_study", "addon_study", "preference_rollback"]
@@ -341,17 +343,17 @@ normandy:
       preference_rollback, this is fired when the recipe is fired (the
       recipe that "ends" is a corresponding preference_rollout).
     extra_keys:
       reason: A code describing the reason why the recipe ended.
       didResetValue: >
         For preference_study, "true" or "false" according to whether we put the preference back the way it was.
       addonId: For addon_study, the ID of the addon that ended.
       addonVersion: For addon_study, the version of the addon for which the recipe ended.
-      branch: For preference_study, the branch of the experiment that this client was on.
+      branch: The branch of the experiment that this client was on.
     bug_numbers: [1443560]
     notification_emails: ["normandy-notifications@mozilla.com"]
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   unenroll_failed:
     methods: ["unenrollFailed"]
--- a/toolkit/components/utils/JsonSchemaValidator.jsm
+++ b/toolkit/components/utils/JsonSchemaValidator.jsm
@@ -66,16 +66,17 @@ function validateAndParseParamRecursive(
   switch (properties.type) {
     case "boolean":
     case "number":
     case "integer":
     case "string":
     case "URL":
     case "URLorEmpty":
     case "origin":
+    case "null":
       return validateAndParseSimpleParam(param, properties.type);
 
     case "array":
       if (!Array.isArray(param)) {
         log.error("Array expected but not received");
         return [false, null];
       }
 
@@ -197,16 +198,20 @@ function validateAndParseSimpleParam(par
       valid = (typeof(param) == type);
       break;
 
     // integer is an alias to "number" that some JSON schema tools use
     case "integer":
       valid = (typeof(param) == "number");
       break;
 
+    case "null":
+      valid = param === null;
+      break;
+
     case "origin":
       if (typeof(param) != "string") {
         break;
       }
 
       try {
         parsedParam = new URL(param);
 
--- a/toolkit/components/utils/test/browser/browser_JsonSchemaValidator.js
+++ b/toolkit/components/utils/test/browser/browser_JsonSchemaValidator.js
@@ -60,16 +60,34 @@ add_task(async function test_integer_val
 
   // Invalid values:
   ok(!JsonSchemaValidator.validateAndParseParameters("1", schema)[0], "No type coercion");
   ok(!JsonSchemaValidator.validateAndParseParameters(true, schema)[0], "Invalid value");
   ok(!JsonSchemaValidator.validateAndParseParameters({}, schema)[0], "Invalid value");
   ok(!JsonSchemaValidator.validateAndParseParameters(null, schema)[0], "Invalid value");
 });
 
+add_task(async function test_null_values() {
+  let schema = {
+    type: "null",
+  };
+
+  let valid, parsed;
+  [valid, parsed] = JsonSchemaValidator.validateAndParseParameters(null, schema);
+  ok(valid, "Null should be valid");
+  ok(parsed === null, "Parsed value should be null");
+
+  // Invalid values:
+  ok(!JsonSchemaValidator.validateAndParseParameters(1, schema)[0], "Number should be invalid");
+  ok(!JsonSchemaValidator.validateAndParseParameters("1", schema)[0], "String should be invalid");
+  ok(!JsonSchemaValidator.validateAndParseParameters(true, schema)[0], "Boolean should be invalid");
+  ok(!JsonSchemaValidator.validateAndParseParameters({}, schema)[0], "Object should be invalid");
+  ok(!JsonSchemaValidator.validateAndParseParameters([], schema)[0], "Array should be invalid");
+});
+
 add_task(async function test_string_values() {
   let schema = {
     type: "string",
   };
 
   let valid, parsed;
   [valid, parsed] = JsonSchemaValidator.validateAndParseParameters("foobar", schema);
   ok(valid && parsed == "foobar", "Parsed string value correctly");
@@ -196,17 +214,17 @@ add_task(async function test_array_value
 
   // Invalid values:
   ok(!JsonSchemaValidator.validateAndParseParameters([1, true, 3], schema)[0], "Mixed types");
   ok(!JsonSchemaValidator.validateAndParseParameters(2, schema)[0], "Type is correct but not in an array");
   ok(!JsonSchemaValidator.validateAndParseParameters({}, schema)[0], "Object is not an array");
 });
 
 add_task(async function test_non_strict_arrays() {
-  // Non-srict arrays ignores invalid values (don't include
+  // Non-strict arrays ignores invalid values (don't include
   // them in the parsed output), instead of failing the validation.
   // Note: invalid values might still report errors to the console.
   let schema = {
     type: "array",
     strict: false,
     items: {
       type: "string",
     },
@@ -418,16 +436,37 @@ add_task(async function test_number_or_a
   ok(!JsonSchemaValidator.validateAndParseParameters(true, schema)[0], "Invalid value");
   ok(!JsonSchemaValidator.validateAndParseParameters({}, schema)[0], "Invalid value");
   ok(!JsonSchemaValidator.validateAndParseParameters(null, schema)[0], "Invalid value");
   ok(!JsonSchemaValidator.validateAndParseParameters(["a", "b"], schema)[0], "Invalid value");
   ok(!JsonSchemaValidator.validateAndParseParameters([[]], schema)[0], "Invalid value");
   ok(!JsonSchemaValidator.validateAndParseParameters([0, 1, [2, 3]], schema)[0], "Invalid value");
 });
 
+add_task(function test_number_or_null_Values() {
+  let schema = {
+    type: ["number", "null"],
+  };
+
+  let valid, parsed;
+  [valid, parsed] = JsonSchemaValidator.validateAndParseParameters(1, schema);
+  ok(valid, "Number should be valid");
+  is(parsed, 1, "Number should be parsed correctly");
+
+  [valid, parsed] = JsonSchemaValidator.validateAndParseParameters(null, schema);
+  ok(valid, "Null should be valid");
+  is(parsed, null, "Null should be parsed correctly");
+
+  // Invalid values:
+  ok(!JsonSchemaValidator.validateAndParseParameters(true, schema)[0], "Boolean should be rejected");
+  ok(!JsonSchemaValidator.validateAndParseParameters("string", schema)[0], "String should be rejected");
+  ok(!JsonSchemaValidator.validateAndParseParameters({}, schema)[0], "Object should be rejected");
+  ok(!JsonSchemaValidator.validateAndParseParameters(["a", "b"], schema)[0], "Array should be rejected");
+});
+
 add_task(async function test_patternProperties() {
   let schema = {
     type: "object",
     properties: {
       "S-bool-property": { "type": "boolean" },
     },
     patternProperties: {
       "^S-": { "type": "string" },