Bug 1440782 Part 2 - Add preference-rollout action to Normandy r?Gijs draft
authorMike Cooper <mcooper@mozilla.com>
Thu, 19 Apr 2018 15:37:11 -0700
changeset 785330 4850c0ace4797ec4c5ae9a63ad8f02b032b4ae17
parent 785329 c8ae85f4be9bfcc04b7f1f2b01241d54e2d070d4
child 785331 4b9de5f9c430e6bbdd7e224c4ed31c3eaa979b7a
child 785755 55f427aa73f96e810fcbd0fd55cc481bcde6cae1
child 785756 10bbdb7d115360d0dd8de89468a321d1d6623624
child 785813 fec17e51def88d811665bf8bc3c1129b7ae56a4c
push id107199
push userbmo:mcooper@mozilla.com
push dateThu, 19 Apr 2018 22:38:16 +0000
reviewersGijs
bugs1440782
milestone61.0a1
Bug 1440782 Part 2 - Add preference-rollout action to Normandy r?Gijs MozReview-Commit-ID: 2ItLoSxlbC
toolkit/components/normandy/Normandy.jsm
toolkit/components/normandy/actions/BaseAction.jsm
toolkit/components/normandy/actions/ConsoleLog.jsm
toolkit/components/normandy/actions/ConsoleLogAction.jsm
toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
toolkit/components/normandy/actions/schemas/index.js
toolkit/components/normandy/jar.mn
toolkit/components/normandy/lib/ActionSandboxManager.jsm
toolkit/components/normandy/lib/ActionsManager.jsm
toolkit/components/normandy/lib/AddonStudies.jsm
toolkit/components/normandy/lib/NormandyApi.jsm
toolkit/components/normandy/lib/PrefUtils.jsm
toolkit/components/normandy/lib/PreferenceExperiments.jsm
toolkit/components/normandy/lib/PreferenceRollouts.jsm
toolkit/components/normandy/lib/RecipeRunner.jsm
toolkit/components/normandy/lib/TelemetryEvents.jsm
toolkit/components/normandy/test/browser/browser.ini
toolkit/components/normandy/test/browser/browser_Normandy.js
toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js
toolkit/components/normandy/test/browser/browser_PreferenceRollouts.js
toolkit/components/normandy/test/browser/browser_action_ConsoleLog.js
toolkit/components/normandy/test/browser/browser_actions_ConsoleLogAction.js
toolkit/components/normandy/test/browser/browser_actions_PreferenceRolloutAction.js
--- a/toolkit/components/normandy/Normandy.jsm
+++ b/toolkit/components/normandy/Normandy.jsm
@@ -9,58 +9,63 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AboutPages: "resource://normandy-content/AboutPages.jsm",
   AddonStudies: "resource://normandy/lib/AddonStudies.jsm",
   CleanupManager: "resource://normandy/lib/CleanupManager.jsm",
   LogManager: "resource://normandy/lib/LogManager.jsm",
   PreferenceExperiments: "resource://normandy/lib/PreferenceExperiments.jsm",
+  PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.jsm",
   RecipeRunner: "resource://normandy/lib/RecipeRunner.jsm",
   ShieldPreferences: "resource://normandy/lib/ShieldPreferences.jsm",
   TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
 });
 
 var EXPORTED_SYMBOLS = ["Normandy"];
 
 const UI_AVAILABLE_NOTIFICATION = "sessionstore-windows-restored";
 const BOOTSTRAP_LOGGER_NAME = "app.normandy.bootstrap";
 const SHIELD_INIT_NOTIFICATION = "shield-init-complete";
 
 const PREF_PREFIX = "app.normandy";
 const LEGACY_PREF_PREFIX = "extensions.shield-recipe-client";
 const STARTUP_EXPERIMENT_PREFS_BRANCH = `${PREF_PREFIX}.startupExperimentPrefs.`;
+const STARTUP_ROLLOUT_PREFS_BRANCH = `${PREF_PREFIX}.startupRolloutPrefs.`;
 const PREF_LOGGING_LEVEL = `${PREF_PREFIX}.logging.level`;
 
 // Logging
 const log = Log.repository.getLogger(BOOTSTRAP_LOGGER_NAME);
 log.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
 log.level = Services.prefs.getIntPref(PREF_LOGGING_LEVEL, Log.Level.Warn);
 
-let studyPrefsChanged = {};
+var Normandy = {
+  studyPrefsChanged: {},
+  rolloutPrefsChanged: {},
 
-var Normandy = {
   init() {
     // Initialization that needs to happen before the first paint on startup.
     this.migrateShieldPrefs();
-    this.initExperimentPrefs();
+    this.rolloutPrefsChanged = this.applyStartupPrefs(STARTUP_ROLLOUT_PREFS_BRANCH);
+    this.studyPrefsChanged = this.applyStartupPrefs(STARTUP_EXPERIMENT_PREFS_BRANCH);
 
     // Wait until the UI is available before finishing initialization.
     Services.obs.addObserver(this, UI_AVAILABLE_NOTIFICATION);
   },
 
   observe(subject, topic, data) {
     if (topic === UI_AVAILABLE_NOTIFICATION) {
       Services.obs.removeObserver(this, UI_AVAILABLE_NOTIFICATION);
       this.finishInit();
     }
   },
 
   async finishInit() {
-    await PreferenceExperiments.recordOriginalValues(studyPrefsChanged);
+    await PreferenceRollouts.recordOriginalValues(this.rolloutPrefsChanged);
+    await PreferenceExperiments.recordOriginalValues(this.studyPrefsChanged);
 
     // Setup logging and listen for changes to logging prefs
     LogManager.configure(Services.prefs.getIntPref(PREF_LOGGING_LEVEL, Log.Level.Warn));
     Services.prefs.addObserver(PREF_LOGGING_LEVEL, LogManager.configure);
     CleanupManager.addCleanupHandler(
       () => Services.prefs.removeObserver(PREF_LOGGING_LEVEL, LogManager.configure),
     );
 
@@ -78,16 +83,22 @@ var Normandy = {
 
     try {
       await AddonStudies.init();
     } catch (err) {
       log.error("Failed to initialize addon studies:", err);
     }
 
     try {
+      await PreferenceRollouts.init();
+    } catch (err) {
+      log.error("Failed to initialize preference rollouts:", err);
+    }
+
+    try {
       await PreferenceExperiments.init();
     } catch (err) {
       log.error("Failed to initialize preference experiments:", err);
     }
 
     try {
       ShieldPreferences.init();
     } catch (err) {
@@ -96,16 +107,17 @@ var Normandy = {
 
     await RecipeRunner.init();
     Services.obs.notifyObservers(null, SHIELD_INIT_NOTIFICATION);
   },
 
   async uninit() {
     await CleanupManager.cleanup();
     Services.prefs.removeObserver(PREF_LOGGING_LEVEL, LogManager.configure);
+    await PreferenceRollouts.uninit();
 
     // In case the observer didn't run, clean it up.
     try {
       Services.obs.removeObserver(this, UI_AVAILABLE_NOTIFICATION);
     } catch (err) {
       // It must already be removed!
     }
   },
@@ -147,81 +159,90 @@ var Normandy = {
           // This should never happen either.
           log.error(`Error getting startup pref ${prefName}; unknown value type ${legacyPrefType}.`);
       }
 
       legacyBranch.clearUserPref(prefName);
     }
   },
 
-  initExperimentPrefs() {
-    studyPrefsChanged = {};
-    const defaultBranch = Services.prefs.getDefaultBranch("");
-    const experimentBranch = Services.prefs.getBranch(STARTUP_EXPERIMENT_PREFS_BRANCH);
+  /**
+   * Copy a preference subtree from one branch to another, being careful about
+   * types, and return the values the target branch originally had. Prefs will
+   * be read from the user branch and applied to the default branch.
+   * @param sourcePrefix
+   *   The pref prefix to read prefs from.
+   * @returns
+   *   The original values that each pref had on the default branch.
+   */
+  applyStartupPrefs(sourcePrefix) {
+    const originalValues = {};
+    const sourceBranch = Services.prefs.getBranch(sourcePrefix);
+    const targetBranch = Services.prefs.getDefaultBranch("");
 
-    for (const prefName of experimentBranch.getChildList("")) {
-      const experimentPrefType = experimentBranch.getPrefType(prefName);
-      const realPrefType = defaultBranch.getPrefType(prefName);
+    for (const prefName of sourceBranch.getChildList("")) {
+      const sourcePrefType = sourceBranch.getPrefType(prefName);
+      const targetPrefType = targetBranch.getPrefType(prefName);
 
-      if (realPrefType !== Services.prefs.PREF_INVALID && realPrefType !== experimentPrefType) {
-        log.error(`Error setting startup pref ${prefName}; pref type does not match.`);
+      if (targetPrefType !== Services.prefs.PREF_INVALID && targetPrefType !== sourcePrefType) {
+        Cu.reportError(new Error(`Error setting startup pref ${prefName}; pref type does not match.`));
         continue;
       }
 
       // record the value of the default branch before setting it
       try {
-        switch (realPrefType) {
-          case Services.prefs.PREF_STRING:
-            studyPrefsChanged[prefName] = defaultBranch.getCharPref(prefName);
+        switch (targetPrefType) {
+          case Services.prefs.PREF_STRING: {
+            originalValues[prefName] = targetBranch.getCharPref(prefName);
             break;
-
-          case Services.prefs.PREF_INT:
-            studyPrefsChanged[prefName] = defaultBranch.getIntPref(prefName);
+          }
+          case Services.prefs.PREF_INT: {
+            originalValues[prefName] = targetBranch.getIntPref(prefName);
             break;
-
-          case Services.prefs.PREF_BOOL:
-            studyPrefsChanged[prefName] = defaultBranch.getBoolPref(prefName);
+          }
+          case Services.prefs.PREF_BOOL: {
+            originalValues[prefName] = targetBranch.getBoolPref(prefName);
             break;
-
-          case Services.prefs.PREF_INVALID:
-            studyPrefsChanged[prefName] = null;
+          }
+          case Services.prefs.PREF_INVALID: {
+            originalValues[prefName] = null;
             break;
-
-          default:
+          }
+          default: {
             // This should never happen
-            log.error(`Error getting startup pref ${prefName}; unknown value type ${experimentPrefType}.`);
+            log.error(`Error getting startup pref ${prefName}; unknown value type ${sourcePrefType}.`);
+          }
         }
       } catch (e) {
         if (e.result === Cr.NS_ERROR_UNEXPECTED) {
           // There is a value for the pref on the user branch but not on the default branch. This is ok.
-          studyPrefsChanged[prefName] = null;
+          originalValues[prefName] = null;
         } else {
-          // rethrow
-          throw e;
+          // Unexpected error, report it and move on
+          Cu.reportError(e);
+          continue;
         }
       }
 
       // now set the new default value
-      switch (experimentPrefType) {
-        case Services.prefs.PREF_STRING:
-          defaultBranch.setCharPref(prefName, experimentBranch.getCharPref(prefName));
+      switch (sourcePrefType) {
+        case Services.prefs.PREF_STRING: {
+          targetBranch.setCharPref(prefName, sourceBranch.getCharPref(prefName));
           break;
-
-        case Services.prefs.PREF_INT:
-          defaultBranch.setIntPref(prefName, experimentBranch.getIntPref(prefName));
+        }
+        case Services.prefs.PREF_INT: {
+          targetBranch.setIntPref(prefName, sourceBranch.getIntPref(prefName));
           break;
-
-        case Services.prefs.PREF_BOOL:
-          defaultBranch.setBoolPref(prefName, experimentBranch.getBoolPref(prefName));
+        }
+        case Services.prefs.PREF_BOOL: {
+          targetBranch.setBoolPref(prefName, sourceBranch.getBoolPref(prefName));
           break;
-
-        case Services.prefs.PREF_INVALID:
+        }
+        default: {
           // This should never happen.
-          log.error(`Error setting startup pref ${prefName}; pref type is invalid (${experimentPrefType}).`);
-          break;
-
-        default:
-          // This should never happen either.
-          log.error(`Error getting startup pref ${prefName}; unknown value type ${experimentPrefType}.`);
+          Cu.reportError(new Error(`Error getting startup pref ${prefName}; unexpected value type ${sourcePrefType}.`));
+        }
       }
     }
+
+    return originalValues;
   },
 };
--- a/toolkit/components/normandy/actions/BaseAction.jsm
+++ b/toolkit/components/normandy/actions/BaseAction.jsm
@@ -23,17 +23,18 @@ class BaseAction {
     this.finalized = false;
     this.failed = false;
     this.log = LogManager.getLogger(`action.${this.name}`);
 
     try {
       this._preExecution();
     } catch (err) {
       this.failed = true;
-      this.log.error(`Could not initialize action ${this.name}: ${err}`);
+      err.message = `Could not initialize action ${this.name}: ${err.message}`;
+      Cu.reportError(err);
       Uptake.reportAction(this.name, Uptake.ACTION_PRE_EXECUTION_ERROR);
     }
   }
 
   get schema() {
     return {
       type: "object",
       properties: {},
@@ -80,17 +81,17 @@ class BaseAction {
     }
 
     recipe.arguments = validatedArguments;
 
     let status = Uptake.RECIPE_SUCCESS;
     try {
       await this._run(recipe);
     } catch (err) {
-      this.log.error(`Could not execute recipe ${recipe.name}: ${err}`);
+      Cu.reportError(err);
       status = Uptake.RECIPE_EXECUTION_ERROR;
     }
     Uptake.reportRecipe(recipe.id, status);
   }
 
   /**
    * Action specific recipe behavior must be implemented here. It
    * will be executed once for reach recipe, being passed the recipe
@@ -112,27 +113,28 @@ class BaseAction {
 
     if (this.failed) {
       this.log.info(`Skipping post-execution hook for ${this.name} due to earlier failure.`);
       return;
     }
 
     let status = Uptake.ACTION_SUCCESS;
     try {
-      this._finalize();
+      await this._finalize();
     } catch (err) {
       status = Uptake.ACTION_POST_EXECUTION_ERROR;
-      this.log.info(`Could not run postExecution hook for ${this.name}: ${err.message}`);
+      err.message = `Could not run postExecution hook for ${this.name}: ${err.message}`;
+      Cu.reportError(err);
     } finally {
       this.finalized = true;
       Uptake.reportAction(this.name, status);
     }
   }
 
   /**
    * Action specific post-execution behavior should be implemented
    * here. It will be executed once after all recipes have been
    * processed.
    */
-  _finalize() {
+  async _finalize() {
     // Does nothing, may be overridden
   }
 }
rename from toolkit/components/normandy/actions/ConsoleLog.jsm
rename to toolkit/components/normandy/actions/ConsoleLogAction.jsm
--- a/toolkit/components/normandy/actions/ConsoleLog.jsm
+++ b/toolkit/components/normandy/actions/ConsoleLogAction.jsm
@@ -2,19 +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/. */
 
 "use strict";
 
 ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
 ChromeUtils.defineModuleGetter(this, "ActionSchemas", "resource://normandy/actions/schemas/index.js");
 
-var EXPORTED_SYMBOLS = ["ConsoleLog"];
+var EXPORTED_SYMBOLS = ["ConsoleLogAction"];
 
-class ConsoleLog extends BaseAction {
+class ConsoleLogAction extends BaseAction {
   get schema() {
-    return ActionSchemas.consoleLog;
+    return ActionSchemas["console-log"];
   }
 
   async _run(recipe) {
     this.log.info(recipe.arguments.message);
   }
 }
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/actions/PreferenceRolloutAction.jsm
@@ -0,0 +1,167 @@
+/* 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/. */
+
+"use strict";
+
+ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(this, "IndexedDB", "resource://gre/modules/IndexedDB.jsm");
+ChromeUtils.defineModuleGetter(this, "TelemetryEnvironment", "resource://gre/modules/TelemetryEnvironment.jsm");
+ChromeUtils.defineModuleGetter(this, "PreferenceRollouts", "resource://normandy/lib/PreferenceRollouts.jsm");
+ChromeUtils.defineModuleGetter(this, "PrefUtils", "resource://normandy/lib/PrefUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "ActionSchemas", "resource://normandy/actions/schemas/index.js");
+ChromeUtils.defineModuleGetter(this, "TelemetryEvents", "resource://normandy/lib/TelemetryEvents.jsm");
+
+var EXPORTED_SYMBOLS = ["PreferenceRolloutAction"];
+
+const PREFERENCE_TYPE_MAP = {
+  boolean: Services.prefs.PREF_BOOL,
+  string: Services.prefs.PREF_STRING,
+  number: Services.prefs.PREF_INT,
+};
+
+class PreferenceRolloutAction extends BaseAction {
+  get schema() {
+    return ActionSchemas["preference-rollout"];
+  }
+
+  async _run(recipe) {
+    const args = recipe.arguments;
+
+    // First determine which preferences are already being managed, to avoid
+    // conflicts between recipes. This will throw if there is a problem.
+    await this._verifyRolloutPrefs(args);
+
+    const newRollout = {
+      slug: args.slug,
+      state: "active",
+      preferences: args.preferences.map(({preferenceName, value}) => ({
+        preferenceName,
+        value,
+        previousValue: null,
+      })),
+    };
+
+    const existingRollout = await PreferenceRollouts.get(args.slug);
+    if (existingRollout) {
+      const anyChanged = await this._updatePrefsForExistingRollout(existingRollout, newRollout);
+
+      // If anything was different about the new rollout, write it to the db and send an event about it
+      if (anyChanged) {
+        await PreferenceRollouts.update(newRollout);
+        TelemetryEvents.sendEvent("update", "preference_rollout", args.slug, {previousState: existingRollout.state});
+
+        switch (existingRollout.state) {
+          case PreferenceRollouts.STATE_ACTIVE: {
+            this.log.debug(`Updated preference rollout ${args.slug}`);
+            break;
+          }
+          case PreferenceRollouts.STATE_GRADUATED: {
+            this.log.debug(`Ungraduated preference rollout ${args.slug}`);
+            TelemetryEnvironment.setExperimentActive(args.slug, newRollout.state, {type: "normandy-prefrollout"});
+            break;
+          }
+          default: {
+            Cu.reportError(new Error(`Updated pref rollout in unexpected state: ${existingRollout.state}`));
+          }
+        }
+      } else {
+        this.log.debug(`No updates to preference rollout ${args.slug}`);
+      }
+
+    } else { // new enrollment
+      for (const prefSpec of newRollout.preferences) {
+        prefSpec.previousValue = PrefUtils.getPref("default", prefSpec.preferenceName);
+      }
+      await PreferenceRollouts.add(newRollout);
+
+      for (const {preferenceName, value} of args.preferences) {
+        PrefUtils.setPref("default", preferenceName, value);
+      }
+
+      this.log.debug(`Enrolled in preference rollout ${args.slug}`);
+      TelemetryEnvironment.setExperimentActive(args.slug, newRollout.state, {type: "normandy-prefrollout"});
+      TelemetryEvents.sendEvent("enroll", "preference_rollout", args.slug, {});
+    }
+  }
+
+  /**
+   * Check that all the preferences in a rollout are ok to set. This means 1) no
+   * other rollout is managing them, and 2) they match the types of the builtin
+   * values.
+   * @param {PreferenceRollout} rollout The arguments from a rollout recipe.
+   * @throws If the preferences are not valid, with details in the error message.
+   */
+  async _verifyRolloutPrefs({slug, preferences}) {
+    const existingManagedPrefs = new Set();
+    for (const rollout of await PreferenceRollouts.getAllActive()) {
+      if (rollout.slug === slug) {
+        continue;
+      }
+      for (const prefSpec of rollout.preferences) {
+        existingManagedPrefs.add(prefSpec.preferenceName);
+      }
+    }
+
+    for (const prefSpec of preferences) {
+      if (existingManagedPrefs.has(prefSpec.preferenceName)) {
+        TelemetryEvents.sendEvent("enrollFailed", "preference_rollout", slug, {reason: "conflict", preference: prefSpec.preferenceName});
+        throw new Error(`Cannot start rollout ${slug}. Preference ${prefSpec.preferenceName} is already managed.`);
+      }
+      const existingPrefType = Services.prefs.getPrefType(prefSpec.preferenceName);
+      const rolloutPrefType = PREFERENCE_TYPE_MAP[typeof prefSpec.value];
+
+      if (existingPrefType !== Services.prefs.PREF_INVALID && existingPrefType !== rolloutPrefType) {
+        TelemetryEvents.sendEvent(
+          "enrollFailed",
+          "preference_rollout",
+          slug,
+          {reason: "invalid type", pref: prefSpec.preferenceName},
+        );
+        throw new Error(
+          `Cannot start rollout "${slug}" on "${prefSpec.preferenceName}". ` +
+          `Existing preference is of type ${existingPrefType}, but rollout ` +
+          `specifies type ${rolloutPrefType}`
+        );
+      }
+    }
+  }
+
+  async _updatePrefsForExistingRollout(existingRollout, newRollout) {
+    let anyChanged = false;
+    const oldPrefSpecs = new Map(existingRollout.preferences.map(p => [p.preferenceName, p]));
+    const newPrefSpecs = new Map(newRollout.preferences.map(p => [p.preferenceName, p]));
+
+    // Check for any preferences that no longer exist, and un-set them.
+    for (const {preferenceName, previousValue} of oldPrefSpecs.values()) {
+      if (!newPrefSpecs.has(preferenceName)) {
+        anyChanged = true;
+        PrefUtils.setPref("default", preferenceName, previousValue);
+      }
+    }
+
+    // Check for any preferences that are new and need added, or changed and need updated.
+    for (const prefSpec of Object.values(newRollout.preferences)) {
+      let oldValue = null;
+      if (oldPrefSpecs.has(prefSpec.preferenceName)) {
+        let oldPrefSpec = oldPrefSpecs.get(prefSpec.preferenceName);
+        if (oldPrefSpec.previousValue !== prefSpec.previousValue) {
+          prefSpec.previousValue = oldPrefSpec.previousValue;
+          anyChanged = true;
+        }
+        oldValue = oldPrefSpec.value;
+      }
+      if (oldValue !== newPrefSpecs.get(prefSpec.preferenceName).value) {
+        anyChanged = true;
+        PrefUtils.setPref("default", prefSpec.preferenceName, prefSpec.value);
+      }
+    }
+    return anyChanged;
+  }
+
+  async _finalize() {
+    await PreferenceRollouts.saveStartupPrefs();
+    await PreferenceRollouts.closeDB();
+  }
+}
--- a/toolkit/components/normandy/actions/schemas/index.js
+++ b/toolkit/components/normandy/actions/schemas/index.js
@@ -1,24 +1,56 @@
 var EXPORTED_SYMBOLS = ["ActionSchemas"];
 
 const ActionSchemas = {
-  consoleLog: {
-    "$schema": "http://json-schema.org/draft-04/schema#",
-    "title": "Log a message to the console",
-    "type": "object",
-    "required": [
-      "message"
-    ],
-    "properties": {
-      "message": {
-        "description": "Message to log to the console",
-        "type": "string",
-        "default": ""
-      }
-    }
-  }
+  "console-log": {
+    $schema: "http://json-schema.org/draft-04/schema#",
+    title: "Log a message to the console",
+    type: "object",
+    required: ["message"],
+    properties: {
+      message: {
+        description: "Message to log to the console",
+        type: "string",
+        default: "",
+      },
+    },
+  },
+
+  "preference-rollout": {
+    $schema: "http://json-schema.org/draft-04/schema#",
+    title: "Change preferences permanently",
+    type: "object",
+    required: ["slug", "preferences"],
+    properties: {
+      slug: {
+        description: "Unique identifer for the rollout, used in telemetry and rollbacks",
+        type: "string",
+        pattern: "^[a-z0-9\\-_]+$",
+      },
+      preferences: {
+        description: "The preferences to change, and their values",
+        type: "array",
+        minItems: 1,
+        items: {
+          type: "object",
+          required: ["preferenceName", "value"],
+          properties: {
+            preferenceName: {
+              "description": "Full dotted-path of the preference being changed",
+              "type": "string",
+            },
+            value: {
+              description: "Value to set the preference to",
+              type: ["string", "number", "boolean"],
+            },
+          },
+        },
+      },
+    },
+  },
 };
 
+// If running in Node.js, export the schemas.
 if (typeof module !== "undefined") {
   /* globals module */
   module.exports = ActionSchemas;
 }
--- a/toolkit/components/normandy/jar.mn
+++ b/toolkit/components/normandy/jar.mn
@@ -2,17 +2,16 @@
 # 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/.
 
 toolkit.jar:
 % resource normandy %res/normandy/
   res/normandy/Normandy.jsm (./Normandy.jsm)
   res/normandy/lib/ (./lib/*)
   res/normandy/skin/  (./skin/*)
-  res/normandy/actions/BaseAction.jsm (./actions/BaseAction.jsm)
-  res/normandy/actions/ConsoleLog.jsm (./actions/ConsoleLog.jsm)
+  res/normandy/actions/ (./actions/*.jsm)
   res/normandy/actions/schemas/index.js (./actions/schemas/index.js)
 
 % resource normandy-content %res/normandy/content/ contentaccessible=yes
   res/normandy/content/ (./content/*)
 
 % resource normandy-vendor %res/normandy/vendor/ contentaccessible=yes
   res/normandy/vendor/ (./vendor/*)
--- a/toolkit/components/normandy/lib/ActionSandboxManager.jsm
+++ b/toolkit/components/normandy/lib/ActionSandboxManager.jsm
@@ -5,18 +5,16 @@
 "use strict";
 
 ChromeUtils.import("resource://normandy/lib/NormandyDriver.jsm");
 ChromeUtils.import("resource://normandy/lib/SandboxManager.jsm");
 ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
 
 var EXPORTED_SYMBOLS = ["ActionSandboxManager"];
 
-const log = LogManager.getLogger("recipe-sandbox-manager");
-
 /**
  * An extension to SandboxManager that prepares a sandbox for executing
  * Normandy actions.
  *
  * Actions register a set of named callbacks, which this class makes available
  * for execution. This allows a single action script to define multiple,
  * independent steps that execute in isolated sandboxes.
  *
@@ -61,17 +59,16 @@ var ActionSandboxManager = class extends
    *   undefined if a matching callback was not found.
    * @rejects
    *   If the sandbox rejects, an error object with the message from the sandbox
    *   error. Due to sandbox limitations, the stack trace is not preserved.
    */
   async runAsyncCallback(callbackName, ...args) {
     const callbackWasRegistered = this.evalInSandbox(`asyncCallbacks.has("${callbackName}")`);
     if (!callbackWasRegistered) {
-      log.debug(`Script did not register a callback with the name "${callbackName}"`);
       return undefined;
     }
 
     this.cloneIntoGlobal("callbackArgs", args);
     const result = await this.evalInSandbox(`
       asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
     `);
     return Cu.cloneInto(result, {});
--- a/toolkit/components/normandy/lib/ActionsManager.jsm
+++ b/toolkit/components/normandy/lib/ActionsManager.jsm
@@ -1,16 +1,17 @@
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   ActionSandboxManager: "resource://normandy/lib/ActionSandboxManager.jsm",
-  ConsoleLog: "resource://normandy/actions/ConsoleLog.jsm",
   NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
   Uptake: "resource://normandy/lib/Uptake.jsm",
+  ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.jsm",
+  PreferenceRolloutAction: "resource://normandy/actions/PreferenceRolloutAction.jsm",
 });
 
 var EXPORTED_SYMBOLS = ["ActionsManager"];
 
 const log = LogManager.getLogger("recipe-runner");
 
 /**
  * A class to manage the actions that recipes can use in Normandy.
@@ -22,17 +23,18 @@ const log = LogManager.getLogger("recipe
  * client, and manage their lifecycles internally.
  */
 class ActionsManager {
   constructor() {
     this.finalized = false;
     this.remoteActionSandboxes = {};
 
     this.localActions = {
-      "console-log": new ConsoleLog(),
+      "console-log": new ConsoleLogAction(),
+      "preference-rollout": new PreferenceRolloutAction(),
     };
   }
 
   async fetchRemoteActions() {
     const actions = await NormandyApi.fetchActions();
 
     for (const action of actions) {
       // Skip actions with local implementations
@@ -52,16 +54,19 @@ class ActionsManager {
         if (/NetworkError/.test(err)) {
           status = Uptake.ACTION_NETWORK_ERROR;
         } else {
           status = Uptake.ACTION_SERVER_ERROR;
         }
         Uptake.reportAction(action.name, status);
       }
     }
+
+    const actionNames = Object.keys(this.remoteActionSandboxes);
+    log.debug(`Fetched ${actionNames.length} actions from the server: ${actionNames.join(", ")}`);
   }
 
   async preExecution() {
     // Local actions run pre-execution hooks implicitly
 
     for (const [actionName, manager] of Object.entries(this.remoteActionSandboxes)) {
       try {
         await manager.runAsyncCallback("preExecution");
--- a/toolkit/components/normandy/lib/AddonStudies.jsm
+++ b/toolkit/components/normandy/lib/AddonStudies.jsm
@@ -336,18 +336,16 @@ var AddonStudies = {
    */
   async stop(recipeId, reason = "unknown") {
     const db = await getDatabase();
     const study = await getStore(db).get(recipeId);
     if (!study) {
       throw new Error(`No study found for recipe ${recipeId}.`);
     }
     if (!study.active) {
-      dump(`@@@ Cannot stop study for recipe ${recipeId}; it is already inactive.\n`);
-      dump(`@@@\n${new Error().stack}\n@@@\n`);
       throw new Error(`Cannot stop study for recipe ${recipeId}; it is already inactive.`);
     }
 
     await markAsEnded(db, study, reason);
 
     try {
       await Addons.uninstall(study.addonId);
     } catch (err) {
--- a/toolkit/components/normandy/lib/NormandyApi.jsm
+++ b/toolkit/components/normandy/lib/NormandyApi.jsm
@@ -125,21 +125,16 @@ var NormandyApi = {
 
       if (!valid) {
         throw new NormandyApi.InvalidSignatureError(`${type} signature is not valid`);
       }
 
       verifiedObjects.push(object);
     }
 
-    log.debug(
-      `Fetched ${verifiedObjects.length} ${type} from the server:`,
-      verifiedObjects.map(r => r.name).join(", ")
-    );
-
     return verifiedObjects;
   },
 
   /**
    * Fetch metadata about this client determined by the server.
    * @return {object} Metadata specified by the server
    */
   async classifyClient() {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/lib/PrefUtils.jsm
@@ -0,0 +1,97 @@
+/* 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/. */
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+var EXPORTED_SYMBOLS = ["PrefUtils"];
+
+const kPrefBranches = {
+  user: Services.prefs,
+  default: Services.prefs.getDefaultBranch(""),
+};
+
+var PrefUtils = {
+  /**
+   * Get a preference from the named branch
+   * @param {string} branchName One of "default" or "user"
+   * @param {string} pref
+   * @param {string|boolean|integer|null} [default]
+   *   The value to return if the preference does not exist. Defaults to null.
+   */
+  getPref(branchName, pref, defaultValue = null) {
+    const branch = kPrefBranches[branchName];
+    const type = branch.getPrefType(pref);
+    switch (type) {
+      case Services.prefs.PREF_BOOL: {
+        return branch.getBoolPref(pref);
+      }
+      case Services.prefs.PREF_STRING: {
+        return branch.getStringPref(pref);
+      }
+      case Services.prefs.PREF_INT: {
+        return branch.getIntPref(pref);
+      }
+      case Services.prefs.PREF_INVALID: {
+        return defaultValue;
+      }
+      default: {
+        // This should never happen
+        throw new TypeError(`Unknown preference type (${type}) for ${pref}.`);
+      }
+    }
+  },
+
+  /**
+   * Set a preference on the named branch
+   * @param {string} branchName One of "default" or "user"
+   * @param {string} pref
+   * @param {string|boolean|integer|null} value
+   *   The value to set. Must match the type named in `type`.
+   */
+  setPref(branchName, pref, value) {
+    if (value === null) {
+      this.clearPref(branchName, pref);
+      return;
+    }
+    const branch = kPrefBranches[branchName];
+    switch (typeof value) {
+      case "boolean": {
+        branch.setBoolPref(pref, value);
+        break;
+      }
+      case "string": {
+        branch.setStringPref(pref, value);
+        break;
+      }
+      case "number": {
+        branch.setIntPref(pref, value);
+        break;
+      }
+      default: {
+        throw new TypeError(`Unexpected value type (${typeof value}) for ${pref}.`);
+      }
+    }
+  },
+
+  /**
+   * Remove a preference from a branch.
+   * @param {string} branchName One of "default" or "user"
+   * @param {string} pref
+   */
+  clearPref(branchName, pref) {
+    if (branchName === "user") {
+      kPrefBranches.user.clearUserPref(pref);
+    } else if (branchName === "default") {
+      // deleteBranch will affect the user branch as well. Get the user-branch
+      // value, and re-set it after clearing the pref.
+      const hadUserValue = Services.prefs.prefHasUserValue(pref);
+      const originalValue = this.getPref("user", pref, null);
+      kPrefBranches.default.deleteBranch(pref);
+      if (hadUserValue) {
+        this.setPref(branchName, pref, originalValue);
+      }
+    }
+  }
+};
--- a/toolkit/components/normandy/lib/PreferenceExperiments.jsm
+++ b/toolkit/components/normandy/lib/PreferenceExperiments.jsm
@@ -504,17 +504,17 @@ var PreferenceExperiments = {
         // this point.
         Services.prefs.getDefaultBranch("").deleteBranch(preferenceName);
       }
     }
 
     experiment.expired = true;
     store.saveSoon();
 
-    TelemetryEnvironment.setExperimentInactive(experimentName, experiment.branch);
+    TelemetryEnvironment.setExperimentInactive(experimentName);
     TelemetryEvents.sendEvent("unenroll", "preference_study", experimentName, {
       didResetValue: resetValue ? "true" : "false",
       reason,
     });
     await this.saveStartupPrefs();
   },
 
   /**
@@ -547,19 +547,17 @@ var PreferenceExperiments = {
     return Object.values(store.data).map(experiment => Object.assign({}, experiment));
   },
 
   /**
   * Get a list of experiment objects for all active experiments.
   * @resolves {Experiment[]}
   */
   async getAllActive() {
-    log.debug("PreferenceExperiments.getAllActive()");
     const store = await ensureStorage();
-
     // Return copies so mutating them doesn't affect the storage.
     return Object.values(store.data).filter(e => !e.expired).map(e => Object.assign({}, e));
   },
 
   /**
    * Check if an experiment exists with the given name.
    * @param {string} experimentName
    * @resolves {boolean} True if the experiment exists, false if it doesn't.
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/lib/PreferenceRollouts.jsm
@@ -0,0 +1,243 @@
+/* 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/. */
+
+"use strict";
+
+ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
+ChromeUtils.defineModuleGetter(this, "Services", "resource://gre/modules/Services.jsm");
+ChromeUtils.defineModuleGetter(this, "IndexedDB", "resource://gre/modules/IndexedDB.jsm");
+ChromeUtils.defineModuleGetter(this, "TelemetryEnvironment", "resource://gre/modules/TelemetryEnvironment.jsm");
+ChromeUtils.defineModuleGetter(this, "PrefUtils", "resource://normandy/lib/PrefUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "TelemetryEvents", "resource://normandy/lib/TelemetryEvents.jsm");
+
+/**
+ * PreferenceRollouts store info about an active or expired preference rollout.
+ * @typedef {object} PreferenceRollout
+ * @property {string} slug
+ *   Unique slug of the experiment
+ * @property {string} state
+ *   The current state of the rollout: "active", "rolled-back", "graduated".
+ *   Active means that Normandy is actively managing therollout. Rolled-back
+ *   means that the rollout was previously active, but has been rolled back for
+ *   this user. Graduated means that the built-in default now matches the
+ *   rollout value, and so Normandy is no longer managing the preference.
+ * @property {Array<PreferenceSpec>} preferences
+ *   An array of preferences specifications involved in the rollout.
+ */
+
+ /**
+  * PreferenceSpec describe how a preference should change during a rollout.
+  * @typedef {object} PreferenceSpec
+  * @property {string} preferenceName
+  *   The preference to modify.
+  * @property {string} preferenceType
+  *   Type of the preference being set.
+  * @property {string|integer|boolean} value
+  *   The value to change the preference to.
+  * @property {string|integer|boolean} previousValue
+  *   The value the preference would have on the default branch if this rollout
+  *   were not active.
+  */
+
+var EXPORTED_SYMBOLS = ["PreferenceRollouts"];
+const STARTUP_PREFS_BRANCH = "app.normandy.startupRolloutPrefs.";
+const DB_NAME = "normandy-preference-rollout";
+const STORE_NAME = "preference-rollouts";
+const DB_OPTIONS = {version: 1};
+
+/**
+ * Create a new connection to the database.
+ */
+function openDatabase() {
+  return IndexedDB.open(DB_NAME, DB_OPTIONS, db => {
+    db.createObjectStore(STORE_NAME, {
+      keyPath: "slug",
+    });
+  });
+}
+
+/**
+ * Cache the database connection so that it is shared among multiple operations.
+ */
+let databasePromise;
+function getDatabase() {
+  if (!databasePromise) {
+    databasePromise = openDatabase();
+  }
+  return databasePromise;
+}
+
+/**
+ * Get a transaction for interacting with the rollout store.
+ *
+ * NOTE: Methods on the store returned by this function MUST be called
+ * synchronously, otherwise the transaction with the store will expire.
+ * This is why the helper takes a database as an argument; if we fetched the
+ * database in the helper directly, the helper would be async and the
+ * transaction would expire before methods on the store were called.
+ */
+function getStore(db) {
+  return db.objectStore(STORE_NAME, "readwrite");
+}
+
+var PreferenceRollouts = {
+  STATE_ACTIVE: "active",
+  STATE_ROLLED_BACK: "rolled-back",
+  STATE_GRADUATED: "graduated",
+
+  /**
+   * Update the rollout database with changes that happened during early startup.
+   * @param {object} rolloutPrefsChanged Map from pref name to previous pref value
+   */
+  async recordOriginalValues(originalPreferences) {
+    for (const rollout of await this.getAllActive()) {
+      let changed = false;
+
+      // Count the number of preferences in this rollout that are now redundant.
+      let prefMatchingDefaultCount = 0;
+
+      for (const prefSpec of rollout.preferences) {
+        const builtInDefault = originalPreferences[prefSpec.preferenceName];
+        if (prefSpec.value === builtInDefault) {
+          prefMatchingDefaultCount++;
+        }
+        // Store the current built-in default. That way, if the preference is
+        // rolled back during the current session (ie, until the browser is
+        // shut down), the correct value will be used.
+        if (prefSpec.previousValue !== builtInDefault) {
+          prefSpec.previousValue = builtInDefault;
+          changed = true;
+        }
+      }
+
+      if (prefMatchingDefaultCount === rollout.preferences.length) {
+        // Firefox's builtin defaults have caught up to the rollout, making all
+        // of the rollout's changes redundant, so graduate the rollout.
+        rollout.state = this.STATE_GRADUATED;
+        changed = true;
+        TelemetryEvents.sendEvent("graduate", "preference_rollout", rollout.slug, {});
+      }
+
+      if (changed) {
+        const db = await getDatabase();
+        await getStore(db).put(rollout);
+      }
+    }
+  },
+
+  async init() {
+    for (const rollout of await this.getAllActive()) {
+      TelemetryEnvironment.setExperimentActive(rollout.slug, rollout.state, {type: "normandy-prefrollout"});
+    }
+  },
+
+  async uninit() {
+    await this.saveStartupPrefs();
+  },
+
+  /**
+   * Test wrapper that temporarily replaces the stored rollout data with fake
+   * data for testing.
+   */
+  withTestMock(testFunction) {
+    return async function inner(...args) {
+      let db = await getDatabase();
+      const oldData = await getStore(db).getAll();
+      await getStore(db).clear();
+      try {
+        await testFunction(...args);
+      } finally {
+        db = await getDatabase();
+        const store = getStore(db);
+        let promises = [store.clear()];
+        for (const d of oldData) {
+          promises.push(store.add(d));
+        }
+        await Promise.all(promises);
+      }
+    };
+  },
+
+  /**
+   * Add a new rollout
+   * @param {PreferenceRollout} rollout
+   */
+  async add(rollout) {
+    const db = await getDatabase();
+    return getStore(db).add(rollout);
+  },
+
+  /**
+   * Update an existing rollout
+   * @param {PreferenceRollout} rollout
+   * @throws If a matching rollout does not exist.
+   */
+  async update(rollout) {
+    const db = await getDatabase();
+    if (!await this.has(rollout.slug)) {
+      throw new Error(`Tried to update ${rollout.slug}, but it doesn't already exist.`);
+    }
+    return getStore(db).put(rollout);
+  },
+
+  /**
+   * Test whether there is a rollout in storage with the given slug.
+   * @param {string} slug
+   * @returns {boolean}
+   */
+  async has(slug) {
+    const db = await getDatabase();
+    const rollout = await getStore(db).get(slug);
+    return !!rollout;
+  },
+
+  /**
+   * Get a rollout by slug
+   * @param {string} slug
+   */
+  async get(slug) {
+    const db = await getDatabase();
+    return getStore(db).get(slug);
+  },
+
+  /** Get all rollouts in the database. */
+  async getAll() {
+    const db = await getDatabase();
+    return getStore(db).getAll();
+  },
+
+  /** Get all rollouts in the "active" state. */
+  async getAllActive() {
+    const rollouts = await this.getAll();
+    return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE);
+  },
+
+  /**
+   * Save in-progress preference rollouts in a sub-branch of the normandy prefs.
+   * On startup, we read these to set the rollout values.
+   */
+  async saveStartupPrefs() {
+    const prefBranch = Services.prefs.getBranch(STARTUP_PREFS_BRANCH);
+    prefBranch.deleteBranch("");
+
+    for (const rollout of await this.getAllActive()) {
+      for (const prefSpec of rollout.preferences) {
+        PrefUtils.setPref("user", STARTUP_PREFS_BRANCH + prefSpec.preferenceName, prefSpec.value);
+      }
+    }
+  },
+
+  /**
+   * Close the current database connection if it is open. If it is not open,
+   * this is a no-op.
+   */
+  async closeDB() {
+    if (databasePromise) {
+      const promise = databasePromise;
+      databasePromise = null;
+      const db = await promise;
+      await db.close();
+    }
+  },
+};
--- a/toolkit/components/normandy/lib/RecipeRunner.jsm
+++ b/toolkit/components/normandy/lib/RecipeRunner.jsm
@@ -201,16 +201,21 @@ var RecipeRunner = {
         // gracefully fail without this info if they need it.
       }
     }
 
     // Fetch recipes before execution in case we fail and exit early.
     let recipes;
     try {
       recipes = await NormandyApi.fetchRecipes({enabled: true});
+      log.debug(
+        `Fetched ${recipes.length} recipes from the server: ` +
+        recipes.map(r => r.name).join(", ")
+      );
+
     } catch (e) {
       const apiUrl = Services.prefs.getCharPref(API_URL_PREF);
       log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
 
       let status = Uptake.RUNNER_SERVER_ERROR;
       if (/NetworkError/.test(e)) {
         status = Uptake.RUNNER_NETWORK_ERROR;
       } else if (e instanceof NormandyApi.InvalidSignatureError) {
--- a/toolkit/components/normandy/lib/TelemetryEvents.jsm
+++ b/toolkit/components/normandy/lib/TelemetryEvents.jsm
@@ -10,33 +10,47 @@ var EXPORTED_SYMBOLS = ["TelemetryEvents
 
 const TELEMETRY_CATEGORY = "normandy";
 
 const TelemetryEvents = {
   init() {
     Services.telemetry.registerEvents(TELEMETRY_CATEGORY, {
       enroll: {
         methods: ["enroll"],
-        objects: ["preference_study", "addon_study"],
+        objects: ["preference_study", "addon_study", "preference_rollout"],
         extra_keys: ["experimentType", "branch", "addonId", "addonVersion"],
         record_on_release: true,
       },
 
       enroll_failure: {
         methods: ["enrollFailed"],
-        objects: ["addon_study"],
-        extra_keys: ["reason"],
+        objects: ["addon_study", "preference_rollout"],
+        extra_keys: ["reason", "preference"],
+        record_on_release: true,
+      },
+
+      update: {
+        methods: ["update"],
+        objects: ["preference_rollout"],
+        extra_keys: ["previousState"],
         record_on_release: true,
       },
 
       unenroll: {
         methods: ["unenroll"],
         objects: ["preference_study", "addon_study"],
         extra_keys: ["reason", "didResetValue", "addonId", "addonVersion"],
         record_on_release: true,
       },
+
+      graduated: {
+        methods: ["graduated"],
+        objects: ["preference_rollout"],
+        extra_keys: [],
+        record_on_release: true,
+      },
     });
   },
 
   sendEvent(method, object, value, extra) {
     Services.telemetry.recordEvent(TELEMETRY_CATEGORY, method, object, value, extra);
   },
 };
--- a/toolkit/components/normandy/test/browser/browser.ini
+++ b/toolkit/components/normandy/test/browser/browser.ini
@@ -3,26 +3,28 @@ support-files =
   action_server.sjs
   fixtures/normandy.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]
 skip-if = true # bug 1442712
-[browser_action_ConsoleLog.js]
+[browser_actions_ConsoleLogAction.js]
+[browser_actions_PreferenceRolloutAction.js]
 [browser_ActionSandboxManager.js]
 [browser_ActionsManager.js]
 [browser_Addons.js]
 [browser_AddonStudies.js]
 [browser_BaseAction.js]
 [browser_CleanupManager.js]
 [browser_ClientEnvironment.js]
 [browser_EventEmitter.js]
 [browser_FilterExpressions.js]
 [browser_Heartbeat.js]
 [browser_LogManager.js]
 [browser_Normandy.js]
 [browser_NormandyDriver.js]
 [browser_PreferenceExperiments.js]
+[browser_PreferenceRollouts.js]
 [browser_RecipeRunner.js]
 [browser_ShieldPreferences.js]
-[browser_Storage.js]
\ No newline at end of file
+[browser_Storage.js]
--- a/toolkit/components/normandy/test/browser/browser_Normandy.js
+++ b/toolkit/components/normandy/test/browser/browser_Normandy.js
@@ -1,52 +1,54 @@
 "use strict";
 
 ChromeUtils.import("resource://normandy/Normandy.jsm", this);
 ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
 ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm", this);
+ChromeUtils.import("resource://normandy/lib/PreferenceRollouts.jsm", this);
 ChromeUtils.import("resource://normandy/lib/RecipeRunner.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
 ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this);
 
 const experimentPref1 = "test.initExperimentPrefs1";
 const experimentPref2 = "test.initExperimentPrefs2";
 const experimentPref3 = "test.initExperimentPrefs3";
 const experimentPref4 = "test.initExperimentPrefs4";
 
 function withStubInits(testFunction) {
   return decorate(
     withStub(AboutPages, "init"),
     withStub(AddonStudies, "init"),
+    withStub(PreferenceRollouts, "init"),
     withStub(PreferenceExperiments, "init"),
     withStub(RecipeRunner, "init"),
     withStub(TelemetryEvents, "init"),
-    testFunction
+    () => testFunction(),
   );
 }
 
 decorate_task(
   withPrefEnv({
     set: [
       [`app.normandy.startupExperimentPrefs.${experimentPref1}`, true],
       [`app.normandy.startupExperimentPrefs.${experimentPref2}`, 2],
       [`app.normandy.startupExperimentPrefs.${experimentPref3}`, "string"],
     ],
   }),
-  async function testInitExperimentPrefs() {
+  async function testApplyStartupPrefs() {
     const defaultBranch = Services.prefs.getDefaultBranch("");
     for (const pref of [experimentPref1, experimentPref2, experimentPref3]) {
       is(
         defaultBranch.getPrefType(pref),
         defaultBranch.PREF_INVALID,
         `Pref ${pref} don't exist before being initialized.`,
       );
     }
 
-    Normandy.initExperimentPrefs();
+    Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs.");
 
     ok(
       defaultBranch.getBoolPref(experimentPref1),
       `Pref ${experimentPref1} has a default value after being initialized.`,
     );
     is(
       defaultBranch.getIntPref(experimentPref2),
       2,
@@ -70,42 +72,42 @@ decorate_task(
 );
 
 decorate_task(
   withPrefEnv({
     set: [
       ["app.normandy.startupExperimentPrefs.test.existingPref", "experiment"],
     ],
   }),
-  async function testInitExperimentPrefsExisting() {
+  async function testApplyStartupPrefsExisting() {
     const defaultBranch = Services.prefs.getDefaultBranch("");
     defaultBranch.setCharPref("test.existingPref", "default");
-    Normandy.initExperimentPrefs();
+    Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs.");
     is(
       defaultBranch.getCharPref("test.existingPref"),
       "experiment",
-      "initExperimentPrefs overwrites the default values of existing preferences.",
+      "applyStartupPrefs overwrites the default values of existing preferences.",
     );
   },
 );
 
 decorate_task(
   withPrefEnv({
     set: [
       ["app.normandy.startupExperimentPrefs.test.mismatchPref", "experiment"],
     ],
   }),
-  async function testInitExperimentPrefsMismatch() {
+  async function testApplyStartupPrefsMismatch() {
     const defaultBranch = Services.prefs.getDefaultBranch("");
     defaultBranch.setIntPref("test.mismatchPref", 2);
-    Normandy.initExperimentPrefs();
+    Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs.");
     is(
       defaultBranch.getPrefType("test.mismatchPref"),
       Services.prefs.PREF_INT,
-      "initExperimentPrefs skips prefs that don't match the existing default value's type.",
+      "applyStartupPrefs skips prefs that don't match the existing default value's type.",
     );
   },
 );
 
 decorate_task(
   withStub(Normandy, "finishInit"),
   async function testStartupDelayed(finishInitStub) {
     Normandy.init();
@@ -120,66 +122,56 @@ decorate_task(
       "Once the sessionstore-windows-restored event is observed, finishInit should be called.",
     );
   },
 );
 
 // During startup, preferences that are changed for experiments should
 // be record by calling PreferenceExperiments.recordOriginalValues.
 decorate_task(
-  withPrefEnv({
-    set: [
-      [`app.normandy.startupExperimentPrefs.${experimentPref1}`, true],
-      [`app.normandy.startupExperimentPrefs.${experimentPref2}`, 2],
-      [`app.normandy.startupExperimentPrefs.${experimentPref3}`, "string"],
-      [`app.normandy.startupExperimentPrefs.${experimentPref4}`, "another string"],
-    ],
-  }),
   withStub(PreferenceExperiments, "recordOriginalValues"),
-  async function testInitExperimentPrefs(recordOriginalValuesStub) {
+  withStub(PreferenceRollouts, "recordOriginalValues"),
+  async function testApplyStartupPrefs(experimentsRecordOriginalValuesStub, rolloutsRecordOriginalValueStub) {
     const defaultBranch = Services.prefs.getDefaultBranch("");
 
     defaultBranch.setBoolPref(experimentPref1, false);
     defaultBranch.setIntPref(experimentPref2, 1);
     defaultBranch.setCharPref(experimentPref3, "original string");
     // experimentPref4 is left unset
 
-    Normandy.initExperimentPrefs();
+    Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs.");
+    Normandy.studyPrefsChanged = {"test.study-pref": 1};
+    Normandy.rolloutPrefsChanged = {"test.rollout-pref": 1};
     await Normandy.finishInit();
 
     Assert.deepEqual(
-      recordOriginalValuesStub.getCall(0).args,
-      [{
-        [experimentPref1]: false,
-        [experimentPref2]: 1,
-        [experimentPref3]: "original string",
-        [experimentPref4]: null,  // null because it was not initially set.
-      }],
-      "finishInit should record original values of the prefs initExperimentPrefs changed",
+      experimentsRecordOriginalValuesStub.args,
+      [[{"test.study-pref": 1}]],
+      "finishInit should record original values of the study prefs",
     );
-
-    for (const pref of [experimentPref1, experimentPref2, experimentPref3, experimentPref4]) {
-      Services.prefs.clearUserPref(pref);
-      defaultBranch.deleteBranch(pref);
-    }
+    Assert.deepEqual(
+      rolloutsRecordOriginalValueStub.args,
+      [[{"test.rollout-pref": 1}]],
+      "finishInit should record original values of the study prefs",
+    );
   },
 );
 
 // Test that startup prefs are handled correctly when there is a value on the user branch but not the default branch.
 decorate_task(
   withPrefEnv({
     set: [
       ["app.normandy.startupExperimentPrefs.testing.does-not-exist", "foo"],
       ["testing.does-not-exist", "foo"],
     ],
   }),
   withStub(PreferenceExperiments, "recordOriginalValues"),
-  async function testInitExperimentPrefsNoDefaultValue() {
-    Normandy.initExperimentPrefs();
-    ok(true, "initExperimentPrefs should not throw for non-existant prefs");
+  async function testApplyStartupPrefsNoDefaultValue() {
+    Normandy.applyStartupPrefs("app.normandy.startupExperimentPrefs");
+    ok(true, "initExperimentPrefs should not throw for prefs that doesn't exist on the default branch");
   },
 );
 
 decorate_task(
   withStubInits,
   async function testStartup() {
     const initObserved = TestUtils.topicObserved("shield-init-complete");
     await Normandy.finishInit();
@@ -189,66 +181,85 @@ decorate_task(
     ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
     await initObserved;
   }
 );
 
 decorate_task(
   withStubInits,
   async function testStartupPrefInitFail() {
-    PreferenceExperiments.init.returns(Promise.reject(new Error("oh no")));
+    PreferenceExperiments.init.rejects();
 
     await Normandy.finishInit();
     ok(AboutPages.init.called, "startup calls AboutPages.init");
     ok(AddonStudies.init.called, "startup calls AddonStudies.init");
     ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
     ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
     ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
+    ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
   }
 );
 
 decorate_task(
   withStubInits,
   async function testStartupAboutPagesInitFail() {
-    AboutPages.init.returns(Promise.reject(new Error("oh no")));
+    AboutPages.init.rejects();
 
     await Normandy.finishInit();
     ok(AboutPages.init.called, "startup calls AboutPages.init");
     ok(AddonStudies.init.called, "startup calls AddonStudies.init");
     ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
     ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
     ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
+    ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
   }
 );
 
 decorate_task(
   withStubInits,
   async function testStartupAddonStudiesInitFail() {
-    AddonStudies.init.returns(Promise.reject(new Error("oh no")));
+    AddonStudies.init.rejects();
 
     await Normandy.finishInit();
     ok(AboutPages.init.called, "startup calls AboutPages.init");
     ok(AddonStudies.init.called, "startup calls AddonStudies.init");
     ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
     ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
     ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
+    ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
   }
 );
 
 decorate_task(
   withStubInits,
   async function testStartupTelemetryEventsInitFail() {
     TelemetryEvents.init.throws();
 
     await Normandy.finishInit();
     ok(AboutPages.init.called, "startup calls AboutPages.init");
     ok(AddonStudies.init.called, "startup calls AddonStudies.init");
     ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
     ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
     ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
+    ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
+  }
+);
+
+decorate_task(
+  withStubInits,
+  async function testStartupPreferenceRolloutsInitFail() {
+    PreferenceRollouts.init.throws();
+
+    await Normandy.finishInit();
+    ok(AboutPages.init.called, "startup calls AboutPages.init");
+    ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+    ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+    ok(TelemetryEvents.init.called, "startup calls TelemetryEvents.init");
+    ok(PreferenceRollouts.init.called, "startup calls PreferenceRollouts.init");
   }
 );
 
 decorate_task(
   withMockPreferences,
   async function testPrefMigration(mockPreferences) {
     const legacyPref = "extensions.shield-recipe-client.test";
     const migratedPref = "app.normandy.test";
--- a/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js
+++ b/toolkit/components/normandy/test/browser/browser_PreferenceExperiments.js
@@ -713,17 +713,17 @@ decorate_task(
     });
 
     Assert.deepEqual(
       setActiveStub.getCall(0).args,
       ["test", "branch", {type: "normandy-exp"}],
       "Experiment is registered by start()",
     );
     await PreferenceExperiments.stop("test", {reason: "test-reason"});
-    ok(setInactiveStub.calledWith("test", "branch"), "Experiment is unregistered by stop()");
+    Assert.deepEqual(setInactiveStub.args, [["test"]], "Experiment is unregistered by stop()");
 
     Assert.deepEqual(
       sendEventStub.getCall(0).args,
       ["enroll", "preference_study", "test", {
         experimentType: "exp",
         branch: "branch",
       }],
       "PreferenceExperiments.start() should send the correct telemetry event"
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_PreferenceRollouts.js
@@ -0,0 +1,220 @@
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/IndexedDB.jsm", this);
+ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
+ChromeUtils.import("resource://normandy/lib/PreferenceRollouts.jsm", this);
+ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  async function testGetMissing() {
+    is(
+      await PreferenceRollouts.get("does-not-exist"),
+      null,
+      "get should return null when the requested rollout does not exist"
+    );
+  }
+);
+
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  async function testAddUpdateAndGet() {
+    const rollout = {slug: "test-rollout", state: PreferenceRollouts.STATE_ACTIVE, preferences: []};
+    await PreferenceRollouts.add(rollout);
+    let storedRollout = await PreferenceRollouts.get(rollout.slug);
+    Assert.deepEqual(rollout, storedRollout, "get should retrieve a rollout from storage.");
+
+    rollout.state = PreferenceRollouts.STATE_GRADUATED;
+    await PreferenceRollouts.update(rollout);
+    storedRollout = await PreferenceRollouts.get(rollout.slug);
+    Assert.deepEqual(rollout, storedRollout, "get should retrieve a rollout from storage.");
+  },
+);
+
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  async function testCantUpdateNonexistent() {
+    const rollout = {slug: "test-rollout", state: PreferenceRollouts.STATE_ACTIVE, preferences: []};
+    await Assert.rejects(
+      PreferenceRollouts.update(rollout),
+      /doesn't already exist/,
+      "Update should fail if the rollout doesn't exist",
+    );
+    ok(!await PreferenceRollouts.has("test-rollout"), "rollout should not have been added");
+  },
+);
+
+
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  async function testGetAll() {
+    const rollout1 = {slug: "test-rollout-1", preference: []};
+    const rollout2 = {slug: "test-rollout-2", preference: []};
+    await PreferenceRollouts.add(rollout1);
+    await PreferenceRollouts.add(rollout2);
+
+    const storedRollouts = await PreferenceRollouts.getAll();
+    Assert.deepEqual(
+      storedRollouts.sort((a, b) => a.id - b.id),
+      [rollout1, rollout2],
+      "getAll should return every stored rollout.",
+    );
+  }
+);
+
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  async function testGetAllActive() {
+    const rollout1 = {slug: "test-rollout-1", state: PreferenceRollouts.STATE_ACTIVE};
+    const rollout2 = {slug: "test-rollout-2", state: PreferenceRollouts.STATE_GRADUATED};
+    const rollout3 = {slug: "test-rollout-3", state: PreferenceRollouts.STATE_ROLLED_BACK};
+    await PreferenceRollouts.add(rollout1);
+    await PreferenceRollouts.add(rollout2);
+    await PreferenceRollouts.add(rollout3);
+
+    const activeRollouts = await PreferenceRollouts.getAllActive();
+    Assert.deepEqual(activeRollouts, [rollout1], "getAllActive should return only active rollouts");
+  }
+);
+
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  async function testHas() {
+    const rollout = {slug: "test-rollout", preferences: []};
+    await PreferenceRollouts.add(rollout);
+    ok(await PreferenceRollouts.has(rollout.slug), "has should return true for an existing rollout");
+    ok(!await PreferenceRollouts.has("does not exist"), "has should return false for a missing rollout");
+  }
+);
+
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  async function testCloseDatabase() {
+    await PreferenceRollouts.closeDB();
+    const openSpy = sinon.spy(IndexedDB, "open");
+    sinon.assert.notCalled(openSpy);
+
+    try {
+      // Using rollouts at all should open the database, but only once.
+      await PreferenceRollouts.has("foo");
+      await PreferenceRollouts.get("foo");
+      sinon.assert.calledOnce(openSpy);
+      openSpy.reset();
+
+      // close can be called multiple times
+      await PreferenceRollouts.closeDB();
+      await PreferenceRollouts.closeDB();
+      // and don't cause the database to be opened (that would be weird)
+      sinon.assert.notCalled(openSpy);
+
+      // After being closed, new operations cause the database to be opened again, but only once
+      await PreferenceRollouts.has("foo");
+      await PreferenceRollouts.get("foo");
+      sinon.assert.calledOnce(openSpy);
+
+    } finally {
+      openSpy.restore();
+    }
+  }
+);
+
+// recordOriginalValue should update storage to note the original values
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  async function testRecordOriginalValuesUpdatesPreviousValues() {
+    await PreferenceRollouts.add({
+      slug: "test-rollout",
+      state: PreferenceRollouts.STATE_ACTIVE,
+      preferences: [{preferenceName: "test.pref", value: 2, previousValue: null}],
+    });
+
+    await PreferenceRollouts.recordOriginalValues({"test.pref": 1});
+
+    Assert.deepEqual(
+      await PreferenceRollouts.getAll(),
+      [{
+        slug: "test-rollout",
+        state: PreferenceRollouts.STATE_ACTIVE,
+        preferences: [{preferenceName: "test.pref", value: 2, previousValue: 1}],
+      }],
+      "rollout in database should be updated",
+    );
+  },
+);
+
+// recordOriginalValue should graduate a study when it is no longer relevant.
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEvents, "sendEvent"),
+  async function testRecordOriginalValuesUpdatesPreviousValues(sendEventStub) {
+    await PreferenceRollouts.add({
+      slug: "test-rollout",
+      state: PreferenceRollouts.STATE_ACTIVE,
+      preferences: [
+        {preferenceName: "test.pref1", value: 2, previousValue: null},
+        {preferenceName: "test.pref2", value: 2, previousValue: null},
+      ],
+    });
+
+    // one pref being the same isn't enough to graduate
+    await PreferenceRollouts.recordOriginalValues({"test.pref1": 1, "test.pref2": 2});
+    let rollout = await PreferenceRollouts.get("test-rollout");
+    is(
+      rollout.state,
+      PreferenceRollouts.STATE_ACTIVE,
+      "rollouts should remain active when only one pref matches the built-in default",
+    );
+
+    Assert.deepEqual(sendEventStub.args, [], "no events should be sent yet");
+
+    // both prefs is enough
+    await PreferenceRollouts.recordOriginalValues({"test.pref1": 2, "test.pref2": 2});
+    rollout = await PreferenceRollouts.get("test-rollout");
+    is(
+      rollout.state,
+      PreferenceRollouts.STATE_GRADUATED,
+      "rollouts should graduate when all prefs matches the built-in defaults",
+    );
+
+    Assert.deepEqual(
+      sendEventStub.args,
+      [["graduate", "preference_rollout", "test-rollout", {}]],
+      "a graduation event should be sent",
+    );
+  },
+);
+
+// init should mark active rollouts in telemetry
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEnvironment, "setExperimentActive"),
+  async function testInitTelemetry(setExperimentActiveStub) {
+    await PreferenceRollouts.add({
+      slug: "test-rollout-active-1",
+      state: PreferenceRollouts.STATE_ACTIVE,
+    });
+    await PreferenceRollouts.add({
+      slug: "test-rollout-active-2",
+      state: PreferenceRollouts.STATE_ACTIVE,
+    });
+    await PreferenceRollouts.add({
+      slug: "test-rollout-rolled-back",
+      state: PreferenceRollouts.STATE_ROLLED_BACK,
+    });
+    await PreferenceRollouts.add({
+      slug: "test-rollout-graduated",
+      state: PreferenceRollouts.STATE_GRADUATED,
+    });
+
+    await PreferenceRollouts.init();
+
+    Assert.deepEqual(
+      setExperimentActiveStub.args,
+      [
+        ["test-rollout-active-1", "active", {type: "normandy-prefrollout"}],
+        ["test-rollout-active-2", "active", {type: "normandy-prefrollout"}],
+      ],
+      "init should set activate a telemetry experiment for active preferences"
+    );
+  },
+);
rename from toolkit/components/normandy/test/browser/browser_action_ConsoleLog.js
rename to toolkit/components/normandy/test/browser/browser_actions_ConsoleLogAction.js
--- a/toolkit/components/normandy/test/browser/browser_action_ConsoleLog.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_ConsoleLogAction.js
@@ -1,32 +1,32 @@
 "use strict";
 
-ChromeUtils.import("resource://normandy/actions/ConsoleLog.jsm", this);
+ChromeUtils.import("resource://normandy/actions/ConsoleLogAction.jsm", this);
 ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
 
 // Test that logging works
 add_task(async function logging_works() {
-  const action = new ConsoleLog();
+  const action = new ConsoleLogAction();
   const infoStub = sinon.stub(action.log, "info");
   try {
     const recipe = {id: 1, arguments: {message: "Hello, world!"}};
     await action.runRecipe(recipe);
     Assert.deepEqual(infoStub.args, ["Hello, world!"], "the message should be logged");
   } finally {
     infoStub.restore();
   }
 });
 
 
 // test that argument validation works
 decorate_task(
   withStub(Uptake, "reportRecipe"),
   async function arguments_are_validated(reportRecipeStub) {
-    const action = new ConsoleLog();
+    const action = new ConsoleLogAction();
     const infoStub = sinon.stub(action.log, "info");
 
     try {
       // message is required
       let recipe = {id: 1, arguments: {}};
       await action.runRecipe(recipe);
       Assert.deepEqual(infoStub.args, [], "no message should be logged");
       Assert.deepEqual(reportRecipeStub.args, [[recipe.id, Uptake.RECIPE_EXECUTION_ERROR]]);
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/browser_actions_PreferenceRolloutAction.js
@@ -0,0 +1,359 @@
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm", this);
+ChromeUtils.import("resource://gre/modules/Preferences.jsm", this);
+ChromeUtils.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
+ChromeUtils.import("resource://normandy/actions/PreferenceRolloutAction.jsm", this);
+ChromeUtils.import("resource://normandy/lib/PreferenceRollouts.jsm", this);
+ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
+
+// Test that a simple recipe enrolls as expected
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEnvironment, "setExperimentActive"),
+  withStub(TelemetryEvents, "sendEvent"),
+  async function simple_recipe_enrollment(setExperimentActiveStub, sendEventStub) {
+    const recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        preferences: [
+          {preferenceName: "test.pref1", value: 1},
+          {preferenceName: "test.pref2", value: true},
+          {preferenceName: "test.pref3", value: "it works"},
+        ],
+      },
+    };
+
+    const action = new PreferenceRolloutAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    // rollout prefs are set
+    is(Services.prefs.getIntPref("test.pref1"), 1, "integer pref should be set");
+    is(Services.prefs.getBoolPref("test.pref2"), true, "boolean pref should be set");
+    is(Services.prefs.getCharPref("test.pref3"), "it works", "string pref should be set");
+
+    // start up prefs are set
+    is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref1"), 1, "integer startup pref should be set");
+    is(Services.prefs.getBoolPref("app.normandy.startupRolloutPrefs.test.pref2"), true, "boolean startup pref should be set");
+    is(Services.prefs.getCharPref("app.normandy.startupRolloutPrefs.test.pref3"), "it works", "string startup pref should be set");
+
+    // rollout was stored
+    Assert.deepEqual(
+      await PreferenceRollouts.getAll(),
+      [{
+        slug: "test-rollout",
+        state: PreferenceRollouts.STATE_ACTIVE,
+        preferences: [
+          {preferenceName: "test.pref1", value: 1, previousValue: null},
+          {preferenceName: "test.pref2", value: true, previousValue: null},
+          {preferenceName: "test.pref3", value: "it works", previousValue: null},
+        ],
+      }],
+      "Rollout should be stored in db"
+    );
+
+    Assert.deepEqual(
+      sendEventStub.args,
+      [["enroll", "preference_rollout", recipe.arguments.slug, {}]],
+      "an enrollment event should be sent"
+    );
+    Assert.deepEqual(
+      setExperimentActiveStub.args,
+      [["test-rollout", "active", {type: "normandy-prefrollout"}]],
+      "a telemetry experiment should be activated",
+    );
+
+    // Cleanup
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref1");
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref2");
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref3");
+  },
+);
+
+// Test that an enrollment's values can change, be removed, and be added
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEvents, "sendEvent"),
+  async function update_enrollment(sendEventStub) {
+    // first enrollment
+    const recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        preferences: [
+          {preferenceName: "test.pref1", value: 1},
+          {preferenceName: "test.pref2", value: 1},
+        ],
+      },
+    };
+
+    let action = new PreferenceRolloutAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    const defaultBranch = Services.prefs.getDefaultBranch("");
+    is(defaultBranch.getIntPref("test.pref1"), 1, "pref1 should be set");
+    is(defaultBranch.getIntPref("test.pref2"), 1, "pref2 should be set");
+    is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref1"), 1, "startup pref1 should be set");
+    is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref2"), 1, "startup pref2 should be set");
+
+    // update existing enrollment
+    recipe.arguments.preferences = [
+      // pref1 is removed
+      // pref2's value is updated
+      {preferenceName: "test.pref2", value: 2},
+      // pref3 is added
+      {preferenceName: "test.pref3", value: 2},
+    ];
+    action = new PreferenceRolloutAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    is(Services.prefs.getPrefType("test.pref1"), Services.prefs.PREF_INVALID, "pref1 should be removed");
+    is(Services.prefs.getIntPref("test.pref2"), 2, "pref2 should be updated");
+    is(Services.prefs.getIntPref("test.pref3"), 2, "pref3 should be added");
+
+    is(Services.prefs.getPrefType(
+      "app.normandy.startupRolloutPrefs.test.pref1"),
+      Services.prefs.PREF_INVALID,
+      "startup pref1 should be removed",
+    );
+    is(
+      Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref2"),
+      2,
+      "startup pref2 should be updated",
+    );
+    is(
+      Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref3"),
+      2,
+      "startup pref3 should be added",
+    );
+
+    // rollout in the DB has been updated
+    Assert.deepEqual(
+      await PreferenceRollouts.getAll(),
+      [{
+        slug: "test-rollout",
+        state: PreferenceRollouts.STATE_ACTIVE,
+        preferences: [
+          {preferenceName: "test.pref2", value: 2, previousValue: null},
+          {preferenceName: "test.pref3", value: 2, previousValue: null},
+        ],
+      }],
+      "Rollout should be updated in db"
+    );
+
+    Assert.deepEqual(
+      sendEventStub.args,
+      [
+        ["enroll", "preference_rollout", "test-rollout", {}],
+        ["update", "preference_rollout", "test-rollout", {previousState: "active"}],
+      ],
+      "update event was sent"
+    );
+
+    // Cleanup
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref1");
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref2");
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref3");
+  },
+);
+
+// Test that a graduated rollout can be ungraduated
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEvents, "sendEvent"),
+  async function ungraduate_enrollment(sendEventStub) {
+    Services.prefs.getDefaultBranch("").setIntPref("test.pref", 1);
+    await PreferenceRollouts.add({
+      slug: "test-rollout",
+      state: PreferenceRollouts.STATE_GRADUATED,
+      preferences: [{preferenceName: "test.pref", value: 1, previousValue: 1}],
+    });
+
+    let recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        preferences: [{preferenceName: "test.pref", value: 2}],
+      },
+    };
+
+    const action = new PreferenceRolloutAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    is(Services.prefs.getIntPref("test.pref"), 2, "pref should be updated");
+    is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref"), 2, "startup pref should be set");
+
+    // rollout in the DB has been ungraduated
+    Assert.deepEqual(
+      await PreferenceRollouts.getAll(),
+      [{
+        slug: "test-rollout",
+        state: PreferenceRollouts.STATE_ACTIVE,
+        preferences: [{preferenceName: "test.pref", value: 2, previousValue: 1}],
+      }],
+      "Rollout should be updated in db"
+    );
+
+    Assert.deepEqual(
+      sendEventStub.args,
+      [
+        ["update", "preference_rollout", "test-rollout", {previousState: "graduated"}],
+      ],
+      "correct events was sent"
+    );
+
+    // Cleanup
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
+  },
+);
+
+// Test when recipes conflict, only one is applied
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEvents, "sendEvent"),
+  async function conflicting_recipes(sendEventStub) {
+    // create two recipes that each share a pref and have a unique pref.
+    const recipe1 = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout-1",
+        preferences: [
+          {preferenceName: "test.pref1", value: 1},
+          {preferenceName: "test.pref2", value: 1},
+        ],
+      },
+    };
+    const recipe2 = {
+      id: 2,
+      arguments: {
+        slug: "test-rollout-2",
+        preferences: [
+          {preferenceName: "test.pref1", value: 2},
+          {preferenceName: "test.pref3", value: 2},
+        ],
+      },
+    };
+
+    // running both in the same session
+    let action = new PreferenceRolloutAction();
+    await action.runRecipe(recipe1);
+    await action.runRecipe(recipe2);
+    await action.finalize();
+
+    // running recipe2 in a separate session shouldn't change things
+    action = new PreferenceRolloutAction();
+    await action.runRecipe(recipe2);
+    await action.finalize();
+
+    is(Services.prefs.getIntPref("test.pref1"), 1, "pref1 is set to recipe1's value");
+    is(Services.prefs.getIntPref("test.pref2"), 1, "pref2 is set to recipe1's value");
+    is(Services.prefs.getPrefType("test.pref3"), Services.prefs.PREF_INVALID, "pref3 is not set");
+
+    is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref1"), 1, "startup pref1 is set to recipe1's value");
+    is(Services.prefs.getIntPref("app.normandy.startupRolloutPrefs.test.pref2"), 1, "startup pref2 is set to recipe1's value");
+    is(Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref3"), Services.prefs.PREF_INVALID, "startup pref3 is not set");
+
+    // only successful rollout was stored
+    Assert.deepEqual(
+      await PreferenceRollouts.getAll(),
+      [{
+        slug: "test-rollout-1",
+        state: PreferenceRollouts.STATE_ACTIVE,
+        preferences: [
+          {preferenceName: "test.pref1", value: 1, previousValue: null},
+          {preferenceName: "test.pref2", value: 1, previousValue: null},
+        ],
+      }],
+      "Only recipe1's rollout should be stored in db",
+    );
+
+    Assert.deepEqual(
+      sendEventStub.args,
+      [
+        ["enroll", "preference_rollout", recipe1.arguments.slug, {}],
+        ["enrollFailed", "preference_rollout", recipe2.arguments.slug, {reason: "conflict", preference: "test.pref1"}],
+        ["enrollFailed", "preference_rollout", recipe2.arguments.slug, {reason: "conflict", preference: "test.pref1"}],
+      ]
+    );
+
+    // Cleanup
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref1");
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref2");
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref3");
+  },
+);
+
+// Test when the wrong value type is given, the recipe is not applied
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  withStub(TelemetryEvents, "sendEvent"),
+  async function wrong_preference_value(sendEventStub) {
+    Services.prefs.getDefaultBranch("").setCharPref("test.pref", "not an int");
+    const recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        preferences: [{preferenceName: "test.pref", value: 1}],
+      },
+    };
+
+    const action = new PreferenceRolloutAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    is(Services.prefs.getCharPref("test.pref"), "not an int", "the pref should not be modified");
+    is(Services.prefs.getPrefType("app.normandy.startupRolloutPrefs.test.pref"), Services.prefs.PREF_INVALID, "startup pref is not set");
+
+    Assert.deepEqual(await PreferenceRollouts.getAll(), [], "no rollout is stored in the db");
+    Assert.deepEqual(
+      sendEventStub.args,
+      [["enrollFailed", "preference_rollout", recipe.arguments.slug, {reason: "invalid type", pref: "test.pref"}]],
+      "an enrollment failed event should be sent",
+    );
+
+    // Cleanup
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
+  },
+);
+
+// Test that even when applying a rollout, user prefs are preserved
+decorate_task(
+  PreferenceRollouts.withTestMock,
+  async function preserves_user_prefs() {
+    Services.prefs.getDefaultBranch("").setCharPref("test.pref", "builtin value");
+    Services.prefs.setCharPref("test.pref", "user value");
+    const recipe = {
+      id: 1,
+      arguments: {
+        slug: "test-rollout",
+        preferences: [{preferenceName: "test.pref", value: "rollout value"}],
+      }
+    };
+
+    const action = new PreferenceRolloutAction();
+    await action.runRecipe(recipe);
+    await action.finalize();
+
+    is(Services.prefs.getCharPref("test.pref"), "user value", "user branch value should be preserved");
+    is(Services.prefs.getDefaultBranch("").getCharPref("test.pref"), "rollout value", "default branch value should change");
+
+    Assert.deepEqual(
+      await PreferenceRollouts.getAll(),
+      [{
+        slug: "test-rollout",
+        state: PreferenceRollouts.STATE_ACTIVE,
+        preferences: [{preferenceName: "test.pref", value: "rollout value", previousValue: "builtin value"}],
+      }],
+      "the rollout is added to the db with the correct previous value",
+    );
+
+    // Cleanup
+    Services.prefs.getDefaultBranch("").deleteBranch("test.pref");
+    Services.prefs.deleteBranch("test.pref");
+  },
+);