Bug 1440782 Part 2 - Add preference-rollout action to Normandy r=Gijs
authorMike Cooper <mcooper@mozilla.com>
Thu, 19 Apr 2018 15:37:11 -0700
changeset 468321 c760daa73210e215fd864abc09483f706baa265b
parent 468320 8350c61c629e186cf52be47afca865c0558b83f3
child 468322 cba1a84a758fd762a07886960bd0b0c1ac92578c
push id9165
push userasasaki@mozilla.com
push dateThu, 26 Apr 2018 21:04:54 +0000
treeherdermozilla-beta@064c3804de2e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs
bugs1440782
milestone61.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 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");
+  },
+);