Bug 1407067 - Move syncs validation and repair code into a system addon. f?markh draft
authorThom Chiovoloni <tchiovoloni@mozilla.com>
Tue, 07 Nov 2017 19:07:06 -0500
changeset 697276 1a3687df25aa02966441daae45bdf1da785aade0
parent 697275 c616a6fd5e4b20cca139fcdd3957682afaa862b9
child 740077 61bc8dbf5d4849b8a4def55116cfa2025366a420
push id88953
push userbmo:tchiovoloni@mozilla.com
push dateMon, 13 Nov 2017 20:28:47 +0000
bugs1407067
milestone59.0a1
Bug 1407067 - Move syncs validation and repair code into a system addon. f?markh MozReview-Commit-ID: Huybxlhfo6c
browser/extensions/moz.build
browser/extensions/sync-repair/addon_validator.jsm
browser/extensions/sync-repair/bookmark_repair.jsm
browser/extensions/sync-repair/bookmark_validator.jsm
browser/extensions/sync-repair/bootstrap.js
browser/extensions/sync-repair/collection_repair.jsm
browser/extensions/sync-repair/collection_validator.jsm
browser/extensions/sync-repair/doctor.jsm
browser/extensions/sync-repair/form_validator.jsm
browser/extensions/sync-repair/install.rdf.in
browser/extensions/sync-repair/jar.mn
browser/extensions/sync-repair/moz.build
browser/extensions/sync-repair/password_validator.jsm
browser/extensions/sync-repair/repair_manager.jsm
browser/extensions/sync-repair/test/unit/.eslintrc.js
browser/extensions/sync-repair/test/unit/head.js
browser/extensions/sync-repair/test/unit/test_bookmark_repair.js
browser/extensions/sync-repair/test/unit/test_bookmark_repair_requestor.js
browser/extensions/sync-repair/test/unit/test_bookmark_repair_responder.js
browser/extensions/sync-repair/test/unit/test_bookmark_validator.js
browser/extensions/sync-repair/test/unit/test_doctor.js
browser/extensions/sync-repair/test/unit/test_form_validator.js
browser/extensions/sync-repair/test/unit/test_password_validator.js
browser/extensions/sync-repair/test/unit/xpcshell.ini
services/sync/modules/bookmark_repair.js
services/sync/modules/bookmark_validator.js
services/sync/modules/collection_repair.js
services/sync/modules/collection_validator.js
services/sync/modules/doctor.js
services/sync/modules/engines.js
services/sync/modules/engines/addons.js
services/sync/modules/engines/bookmarks.js
services/sync/modules/engines/clients.js
services/sync/modules/engines/forms.js
services/sync/modules/engines/passwords.js
services/sync/modules/service.js
services/sync/modules/stages/enginesync.js
services/sync/moz.build
services/sync/tests/unit/head_helpers.js
services/sync/tests/unit/test_bookmark_duping.js
services/sync/tests/unit/test_bookmark_repair.js
services/sync/tests/unit/test_bookmark_repair_requestor.js
services/sync/tests/unit/test_bookmark_repair_responder.js
services/sync/tests/unit/test_bookmark_validator.js
services/sync/tests/unit/test_collections_recovery.js
services/sync/tests/unit/test_corrupt_keys.js
services/sync/tests/unit/test_doctor.js
services/sync/tests/unit/test_engine_changes_during_sync.js
services/sync/tests/unit/test_errorhandler_1.js
services/sync/tests/unit/test_errorhandler_2.js
services/sync/tests/unit/test_errorhandler_sync_checkServerError.js
services/sync/tests/unit/test_form_validator.js
services/sync/tests/unit/test_fxa_node_reassignment.js
services/sync/tests/unit/test_hmac_error.js
services/sync/tests/unit/test_interval_triggers.js
services/sync/tests/unit/test_node_reassignment.js
services/sync/tests/unit/test_password_engine.js
services/sync/tests/unit/test_password_validator.js
services/sync/tests/unit/test_score_triggers.js
services/sync/tests/unit/test_service_detect_upgrade.js
services/sync/tests/unit/test_service_login.js
services/sync/tests/unit/test_service_sync_remoteSetup.js
services/sync/tests/unit/test_service_sync_specified.js
services/sync/tests/unit/test_service_sync_updateEnabledEngines.js
services/sync/tests/unit/test_syncscheduler.js
services/sync/tests/unit/test_telemetry.js
services/sync/tests/unit/xpcshell.ini
services/sync/tps/extensions/tps/resource/tps.jsm
tools/lint/eslint/modules.json
--- a/browser/extensions/moz.build
+++ b/browser/extensions/moz.build
@@ -9,16 +9,17 @@ DIRS += [
     'aushelper',
     'followonsearch',
     'formautofill',
     'onboarding',
     'pdfjs',
     'pocket',
     'screenshots',
     'shield-recipe-client',
+    'sync-repair',
     'webcompat',
 ]
 
 # Only include the following system add-ons if building Aurora or Nightly
 if not CONFIG['RELEASE_OR_BETA']:
     DIRS += [
         'flyweb',
         'presentation',
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/addon_validator.jsm
@@ -0,0 +1,76 @@
+/* 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";
+
+var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://sync-repair/collection_validator.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+                                  "resource://gre/modules/AddonManager.jsm");
+
+this.EXPORTED_SYMBOLS = ["AddonsValidator"];
+
+class AddonValidator extends CollectionValidator {
+  constructor() {
+    super("addons", "id", [
+      "addonID",
+      "enabled",
+      "applicationID",
+      "source"
+    ]);
+  }
+
+  async getClientItems() {
+    const installed = await AddonManager.getAllAddons();
+    const addonsWithPendingOperation = await AddonManager.getAddonsWithOperationsByTypes(["extension", "theme"]);
+    // Addons pending install won't be in the first list, but addons pending
+    // uninstall/enable/disable will be in both lists.
+    let all = new Map(installed.map(addon => [addon.id, addon]));
+    for (let addon of addonsWithPendingOperation) {
+      all.set(addon.id, addon);
+    }
+    // Convert to an array since Map.prototype.values returns an iterable
+    return [...all.values()];
+  }
+
+  normalizeClientItem(item) {
+    let enabled = !item.userDisabled;
+    if (item.pendingOperations & AddonManager.PENDING_ENABLE) {
+      enabled = true;
+    } else if (item.pendingOperations & AddonManager.PENDING_DISABLE) {
+      enabled = false;
+    }
+    return {
+      enabled,
+      id: item.syncGUID,
+      addonID: item.id,
+      applicationID: Services.appinfo.ID,
+      source: "amo", // check item.foreignInstall?
+      original: item
+    };
+  }
+
+  async normalizeServerItem(item) {
+    let guid = await this.engine._findDupe(item);
+    if (guid) {
+      item.id = guid;
+    }
+    return item;
+  }
+
+  clientUnderstands(item) {
+    return item.applicationID === Services.appinfo.ID;
+  }
+
+  syncedByClient(item) {
+    return !item.original.hidden &&
+           !item.original.isSystem &&
+           !(item.original.pendingOperations & AddonManager.PENDING_UNINSTALL) &&
+           this.engine.isAddonSyncable(item.original, true);
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/bookmark_repair.jsm
@@ -0,0 +1,755 @@
+/* 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";
+
+const Cu = Components.utils;
+
+this.EXPORTED_SYMBOLS = ["BookmarkRepairRequestor", "BookmarkRepairResponder"];
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://services-sync/util.js");
+Cu.import("resource://services-sync/constants.js");
+Cu.import("resource://services-sync/resource.js");
+Cu.import("resource://services-sync/telemetry.js");
+Cu.import("resource://services-common/async.js");
+Cu.import("resource://services-common/utils.js");
+
+Cu.import("resource://sync-repair/collection_repair.jsm");
+Cu.import("resource://sync-repair/doctor.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesSyncUtils",
+                                  "resource://gre/modules/PlacesSyncUtils.jsm");
+
+const log = Log.repository.getLogger("Sync.Engine.Bookmarks.Repair");
+
+const PREF_BRANCH = "services.sync.repairs.bookmarks.";
+
+// How long should we wait after sending a repair request before we give up?
+const RESPONSE_INTERVAL_TIMEOUT = 60 * 60 * 24 * 3; // 3 days
+
+// The maximum number of IDs we will request to be repaired. Beyond this
+// number we assume that trying to repair may do more harm than good and may
+// ask another client to wipe the server and reupload everything. Bug 1341972
+// is tracking that work.
+const MAX_REQUESTED_IDS = 1000;
+
+class AbortRepairError extends Error {
+  constructor(reason) {
+    super();
+    this.reason = reason;
+  }
+}
+
+// The states we can be in.
+const STATE = Object.freeze({
+  NOT_REPAIRING: "",
+
+  // We need to try to find another client to use.
+  NEED_NEW_CLIENT: "repair.need-new-client",
+
+  // We've sent the first request to a client.
+  SENT_REQUEST: "repair.sent",
+
+  // We've retried a request to a client.
+  SENT_SECOND_REQUEST: "repair.sent-again",
+
+  // There were no problems, but we've gone as far as we can.
+  FINISHED: "repair.finished",
+
+  // We've found an error that forces us to abort this entire repair cycle.
+  ABORTED: "repair.aborted",
+});
+
+// The preferences we use to hold our state.
+const PREF = Object.freeze({
+  // If a repair is in progress, this is the generated GUID for the "flow ID".
+  REPAIR_ID: "flowID",
+
+  // The IDs we are currently trying to obtain via the repair process, space sep'd.
+  REPAIR_MISSING_IDS: "ids",
+
+  // The ID of the client we're currently trying to get the missing items from.
+  REPAIR_CURRENT_CLIENT: "currentClient",
+
+  // The IDs of the clients we've previously tried to get the missing items
+  // from, space sep'd.
+  REPAIR_PREVIOUS_CLIENTS: "previousClients",
+
+  // The time, in seconds, when we initiated the most recent client request.
+  REPAIR_WHEN: "when",
+
+  // Our current state.
+  CURRENT_STATE: "state",
+});
+
+class BookmarkRepairRequestor extends CollectionRepairRequestor {
+  constructor(service = null) {
+    super(service);
+    this.prefs = new Preferences(PREF_BRANCH);
+  }
+
+  /* Check if any other clients connected to our account are current performing
+     a repair. A thin wrapper which exists mainly for mocking during tests.
+  */
+  anyClientsRepairing(flowID) {
+    return Doctor.anyClientsRepairing(this.service, "bookmarks", flowID);
+  }
+
+  /* Return a set of IDs we should request.
+  */
+  getProblemIDs(validationInfo) {
+    // Set of ids of "known bad records". Many of the validation issues will
+    // report duplicates -- if the server is missing a record, it is unlikely
+    // to cause only a single problem.
+    let ids = new Set();
+
+    // Note that we allow any of the validation problem fields to be missing so
+    // that tests have a slightly easier time, hence the `|| []` in each loop.
+
+    // Missing children records when the parent exists but a child doesn't.
+    for (let { parent, child } of validationInfo.problems.missingChildren || []) {
+      // We can't be sure if the child is missing or our copy of the parent is
+      // wrong, so request both
+      ids.add(parent);
+      ids.add(child);
+    }
+    if (ids.size > MAX_REQUESTED_IDS) {
+      return ids; // might as well give up here - we aren't going to repair.
+    }
+
+    // Orphans are when the child exists but the parent doesn't.
+    // This could either be a problem in the child (it's wrong about the node
+    // that should be its parent), or the parent could simply be missing.
+    for (let { parent, id } of validationInfo.problems.orphans || []) {
+      // Request both, to handle both cases
+      ids.add(id);
+      ids.add(parent);
+    }
+    if (ids.size > MAX_REQUESTED_IDS) {
+      return ids; // might as well give up here - we aren't going to repair.
+    }
+
+    // Entries where we have the parent but we have a record from the server that
+    // claims the child was deleted.
+    for (let { parent, child } of validationInfo.problems.deletedChildren || []) {
+      // Request both, since we don't know if it's a botched deletion or revival
+      ids.add(parent);
+      ids.add(child);
+    }
+    if (ids.size > MAX_REQUESTED_IDS) {
+      return ids; // might as well give up here - we aren't going to repair.
+    }
+
+    // Entries where the child references a parent that we don't have, but we
+    // have a record from the server that claims the parent was deleted.
+    for (let { parent, child } of validationInfo.problems.deletedParents || []) {
+      // Request both, since we don't know if it's a botched deletion or revival
+      ids.add(parent);
+      ids.add(child);
+    }
+    if (ids.size > MAX_REQUESTED_IDS) {
+      return ids; // might as well give up here - we aren't going to repair.
+    }
+
+    // Cases where the parent and child disagree about who the parent is.
+    for (let { parent, child } of validationInfo.problems.parentChildMismatches || []) {
+      // Request both, since we don't know which is right.
+      ids.add(parent);
+      ids.add(child);
+    }
+    if (ids.size > MAX_REQUESTED_IDS) {
+      return ids; // might as well give up here - we aren't going to repair.
+    }
+
+    // Cases where multiple parents reference a child. We re-request both the
+    // child, and all the parents that think they own it. This may be more than
+    // we need, but I don't think we can safely make the assumption that the
+    // child is right.
+    for (let { parents, child } of validationInfo.problems.multipleParents || []) {
+      for (let parent of parents) {
+        ids.add(parent);
+      }
+      ids.add(child);
+    }
+
+    return ids;
+  }
+
+  _countServerOnlyFixableProblems(validationInfo) {
+    const fixableProblems = ["clientMissing", "serverMissing", "serverDeleted"];
+    return fixableProblems.reduce((numProblems, problemLabel) => {
+      return numProblems + validationInfo.problems[problemLabel].length;
+    }, 0);
+  }
+
+  tryServerOnlyRepairs(validationInfo) {
+    if (this._countServerOnlyFixableProblems(validationInfo) == 0) {
+      return false;
+    }
+    let engine = this.service.engineManager.get("bookmarks");
+    for (let id of validationInfo.problems.serverMissing) {
+      engine.addForWeakUpload(id);
+    }
+    let toFetch = engine.toFetch.concat(validationInfo.problems.clientMissing,
+                                        validationInfo.problems.serverDeleted);
+    engine.toFetch = Array.from(new Set(toFetch));
+    return true;
+  }
+
+  /* See if the repairer is willing and able to begin a repair process given
+     the specified validation information.
+     Returns true if a repair was started and false otherwise.
+  */
+  async startRepairs(validationInfo, flowID) {
+    if (this._currentState != STATE.NOT_REPAIRING) {
+      log.info(`Can't start a repair - repair with ID ${this._flowID} is already in progress`);
+      return false;
+    }
+
+    let ids = this.getProblemIDs(validationInfo);
+    if (ids.size > MAX_REQUESTED_IDS) {
+      log.info("Not starting a repair as there are over " + MAX_REQUESTED_IDS + " problems");
+      let extra = { flowID, reason: `too many problems: ${ids.size}` };
+      this.service.recordTelemetryEvent("repair", "aborted", undefined, extra);
+      return false;
+    }
+
+    if (ids.size == 0) {
+      log.info("Not starting a repair as there are no problems");
+      return false;
+    }
+
+    if (this.anyClientsRepairing()) {
+      log.info("Can't start repair, since other clients are already repairing bookmarks");
+      let extra = { flowID, reason: "other clients repairing" };
+      this.service.recordTelemetryEvent("repair", "aborted", undefined, extra);
+      return false;
+    }
+
+    log.info(`Starting a repair, looking for ${ids.size} missing item(s)`);
+    // setup our prefs to indicate we are on our way.
+    this._flowID = flowID;
+    this._currentIDs = Array.from(ids);
+    this._currentState = STATE.NEED_NEW_CLIENT;
+    this.service.recordTelemetryEvent("repair", "started", undefined, { flowID, numIDs: ids.size.toString() });
+    return this.continueRepairs();
+  }
+
+  /* Work out what state our current repair request is in, and whether it can
+     proceed to a new state.
+     Returns true if we could continue the repair - even if the state didn't
+     actually move. Returns false if we aren't actually repairing.
+  */
+  async continueRepairs(response = null) {
+    // Note that "ABORTED" and "FINISHED" should never be current when this
+    // function returns - this function resets to NOT_REPAIRING in those cases.
+    if (this._currentState == STATE.NOT_REPAIRING) {
+      return false;
+    }
+
+    let state, newState;
+    let abortReason;
+    // we loop until the state doesn't change - but enforce a max of 10 times
+    // to prevent errors causing infinite loops.
+    for (let i = 0; i < 10; i++) {
+      state = this._currentState;
+      log.info("continueRepairs starting with state", state);
+      try {
+        newState = await this._continueRepairs(state, response);
+        log.info("continueRepairs has next state", newState);
+      } catch (ex) {
+        if (!(ex instanceof AbortRepairError)) {
+          throw ex;
+        }
+        log.info(`Repair has been aborted: ${ex.reason}`);
+        newState = STATE.ABORTED;
+        abortReason = ex.reason;
+      }
+
+      if (newState == STATE.ABORTED) {
+        break;
+      }
+
+      this._currentState = newState;
+      Services.prefs.savePrefFile(null); // flush prefs.
+      if (state == newState) {
+        break;
+      }
+    }
+    if (state != newState) {
+      log.error("continueRepairs spun without getting a new state");
+    }
+    if (newState == STATE.FINISHED || newState == STATE.ABORTED) {
+      let object = newState == STATE.FINISHED ? "finished" : "aborted";
+      let extra = {
+        flowID: this._flowID,
+        numIDs: this._currentIDs.length.toString(),
+      };
+      if (abortReason) {
+        extra.reason = abortReason;
+      }
+      this.service.recordTelemetryEvent("repair", object, undefined, extra);
+      // reset our state and flush our prefs.
+      this.prefs.resetBranch();
+      Services.prefs.savePrefFile(null); // flush prefs.
+    }
+    return true;
+  }
+
+  async _continueRepairs(state, response = null) {
+    if (this.anyClientsRepairing(this._flowID)) {
+      throw new AbortRepairError("other clients repairing");
+    }
+    switch (state) {
+      case STATE.SENT_REQUEST:
+      case STATE.SENT_SECOND_REQUEST:
+        let flowID = this._flowID;
+        let clientID = this._currentRemoteClient;
+        if (!clientID) {
+          throw new AbortRepairError(`In state ${state} but have no client IDs listed`);
+        }
+        if (response) {
+          // We got an explicit response - let's see how we went.
+          state = this._handleResponse(state, response);
+          break;
+        }
+        // So we've sent a request - and don't yet have a response. See if the
+        // client we sent it to has removed it from its list (ie, whether it
+        // has synced since we wrote the request.)
+        let client = this.service.clientsEngine.remoteClient(clientID);
+        if (!client) {
+          // hrmph - the client has disappeared.
+          log.info(`previously requested client "${clientID}" has vanished - moving to next step`);
+          state = STATE.NEED_NEW_CLIENT;
+          let extra = {
+            deviceID: this.service.identity.hashedDeviceID(clientID),
+            flowID,
+          };
+          this.service.recordTelemetryEvent("repair", "abandon", "missing", extra);
+          break;
+        }
+        if ((await this._isCommandPending(clientID, flowID))) {
+          // So the command we previously sent is still queued for the client
+          // (ie, that client is yet to have synced). Let's see if we should
+          // give up on that client.
+          let lastRequestSent = this.prefs.get(PREF.REPAIR_WHEN);
+          let timeLeft = lastRequestSent + RESPONSE_INTERVAL_TIMEOUT - this._now();
+          if (timeLeft <= 0) {
+            log.info(`previous request to client ${clientID} is pending, but has taken too long`);
+            state = STATE.NEED_NEW_CLIENT;
+            // XXX - should we remove the command?
+            let extra = {
+              deviceID: this.service.identity.hashedDeviceID(clientID),
+              flowID,
+            };
+            this.service.recordTelemetryEvent("repair", "abandon", "silent", extra);
+            break;
+          }
+          // Let's continue to wait for that client to respond.
+          log.trace(`previous request to client ${clientID} has ${timeLeft} seconds before we give up on it`);
+          break;
+        }
+        // The command isn't pending - if this was the first request, we give
+        // it another go (as that client may have cleared the command but is yet
+        // to complete the sync)
+        // XXX - note that this is no longer true - the responders don't remove
+        // their command until they have written a response. This might mean
+        // we could drop the entire STATE.SENT_SECOND_REQUEST concept???
+        if (state == STATE.SENT_REQUEST) {
+          log.info(`previous request to client ${clientID} was removed - trying a second time`);
+          state = STATE.SENT_SECOND_REQUEST;
+          await this._writeRequest(clientID);
+        } else {
+          // this was the second time around, so give up on this client
+          log.info(`previous 2 requests to client ${clientID} were removed - need a new client`);
+          state = STATE.NEED_NEW_CLIENT;
+        }
+        break;
+
+      case STATE.NEED_NEW_CLIENT:
+        // We need to find a new client to request.
+        let newClientID = this._findNextClient();
+        if (!newClientID) {
+          state = STATE.FINISHED;
+          break;
+        }
+        this._addToPreviousRemoteClients(this._currentRemoteClient);
+        this._currentRemoteClient = newClientID;
+        await this._writeRequest(newClientID);
+        state = STATE.SENT_REQUEST;
+        break;
+
+      case STATE.ABORTED:
+        break; // our caller will take the abort action.
+
+      case STATE.FINISHED:
+        break;
+
+      case STATE.NOT_REPAIRING:
+        // No repair is in progress. This is a common case, so only log trace.
+        log.trace("continue repairs called but no repair in progress.");
+        break;
+
+      default:
+        log.error(`continue repairs finds itself in an unknown state ${state}`);
+        state = STATE.ABORTED;
+        break;
+
+    }
+    return state;
+  }
+
+  /* Handle being in the SENT_REQUEST or SENT_SECOND_REQUEST state with an
+     explicit response.
+  */
+  _handleResponse(state, response) {
+    let clientID = this._currentRemoteClient;
+    let flowID = this._flowID;
+
+    if (response.flowID != flowID || response.clientID != clientID ||
+        response.request != "upload") {
+      log.info("got a response to a different repair request", response);
+      // hopefully just a stale request that finally came in (either from
+      // an entirely different repair flow, or from a client we've since
+      // given up on.) It doesn't mean we need to abort though...
+      return state;
+    }
+    // Pull apart the response and see if it provided everything we asked for.
+    let remainingIDs = Array.from(CommonUtils.difference(this._currentIDs, response.ids));
+    log.info(`repair response from ${clientID} provided "${response.ids}", remaining now "${remainingIDs}"`);
+    this._currentIDs = remainingIDs;
+    if (remainingIDs.length) {
+      // try a new client for the remaining ones.
+      state = STATE.NEED_NEW_CLIENT;
+    } else {
+      state = STATE.FINISHED;
+    }
+    // record telemetry about this
+    let extra = {
+      deviceID: this.service.identity.hashedDeviceID(clientID),
+      flowID,
+      numIDs: response.ids.length.toString(),
+    };
+    this.service.recordTelemetryEvent("repair", "response", "upload", extra);
+    return state;
+  }
+
+  /* Issue a repair request to a specific client.
+  */
+  async _writeRequest(clientID) {
+    log.trace("writing repair request to client", clientID);
+    let ids = this._currentIDs;
+    if (!ids) {
+      throw new AbortRepairError("Attempting to write a request, but there are no IDs");
+    }
+    let flowID = this._flowID;
+    // Post a command to that client.
+    let request = {
+      collection: "bookmarks",
+      request: "upload",
+      requestor: this.service.clientsEngine.localID,
+      ids,
+      flowID,
+    };
+    await this.service.clientsEngine.sendCommand("repairRequest", [request], clientID, { flowID });
+    this.prefs.set(PREF.REPAIR_WHEN, Math.floor(this._now()));
+    // record telemetry about this
+    let extra = {
+      deviceID: this.service.identity.hashedDeviceID(clientID),
+      flowID,
+      numIDs: ids.length.toString(),
+    };
+    this.service.recordTelemetryEvent("repair", "request", "upload", extra);
+  }
+
+  _findNextClient() {
+    let alreadyDone = this._getPreviousRemoteClients();
+    alreadyDone.push(this._currentRemoteClient);
+    let remoteClients = this.service.clientsEngine.remoteClients;
+    // we want to consider the most-recently synced clients first.
+    remoteClients.sort((a, b) => b.serverLastModified - a.serverLastModified);
+    for (let client of remoteClients) {
+      log.trace("findNextClient considering", client);
+      if (alreadyDone.indexOf(client.id) == -1 && this._isSuitableClient(client)) {
+        return client.id;
+      }
+    }
+    log.trace("findNextClient found no client");
+    return null;
+  }
+
+  /* Is the passed client record suitable as a repair responder?
+  */
+  _isSuitableClient(client) {
+    // filter only desktop firefox running > 53 (ie, any 54)
+    return (client.type == DEVICE_TYPE_DESKTOP &&
+            Services.vc.compare(client.version, 53) > 0);
+  }
+
+  /* Is our command still in the "commands" queue for the specific client?
+  */
+  async _isCommandPending(clientID, flowID) {
+    // getClientCommands() is poorly named - it's only outgoing commands
+    // from us we have yet to write. For our purposes, we want to check
+    // them and commands previously written (which is in .commands)
+    let clientCommands = await this.service.clientsEngine.getClientCommands(clientID);
+    let commands = [...clientCommands,
+                    ...this.service.clientsEngine.remoteClient(clientID).commands || []];
+    for (let command of commands) {
+      if (command.command != "repairRequest" || command.args.length != 1) {
+        continue;
+      }
+      let arg = command.args[0];
+      if (arg.collection == "bookmarks" && arg.request == "upload" &&
+          arg.flowID == flowID) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  // accessors for our prefs.
+  get _currentState() {
+    return this.prefs.get(PREF.CURRENT_STATE, STATE.NOT_REPAIRING);
+  }
+  set _currentState(newState) {
+    this.prefs.set(PREF.CURRENT_STATE, newState);
+  }
+
+  get _currentIDs() {
+    let ids = this.prefs.get(PREF.REPAIR_MISSING_IDS, "");
+    return ids.length ? ids.split(" ") : [];
+  }
+  set _currentIDs(arrayOfIDs) {
+    this.prefs.set(PREF.REPAIR_MISSING_IDS, arrayOfIDs.join(" "));
+  }
+
+  get _currentRemoteClient() {
+    return this.prefs.get(PREF.REPAIR_CURRENT_CLIENT);
+  }
+  set _currentRemoteClient(clientID) {
+    this.prefs.set(PREF.REPAIR_CURRENT_CLIENT, clientID);
+  }
+
+  get _flowID() {
+    return this.prefs.get(PREF.REPAIR_ID);
+  }
+  set _flowID(val) {
+    this.prefs.set(PREF.REPAIR_ID, val);
+  }
+
+  // use a function for this pref to offer somewhat sane semantics.
+  _getPreviousRemoteClients() {
+    let alreadyDone = this.prefs.get(PREF.REPAIR_PREVIOUS_CLIENTS, "");
+    return alreadyDone.length ? alreadyDone.split(" ") : [];
+  }
+  _addToPreviousRemoteClients(clientID) {
+    let arrayOfClientIDs = this._getPreviousRemoteClients();
+    arrayOfClientIDs.push(clientID);
+    this.prefs.set(PREF.REPAIR_PREVIOUS_CLIENTS, arrayOfClientIDs.join(" "));
+  }
+
+  /* Used for test mocks.
+  */
+  _now() {
+    // We use the server time, which is SECONDS
+    return Resource.serverTime;
+  }
+}
+
+/* An object that responds to repair requests initiated by some other device.
+*/
+class BookmarkRepairResponder extends CollectionRepairResponder {
+  async repair(request, rawCommand) {
+    if (request.request != "upload") {
+      await this._abortRepair(request, rawCommand,
+                              `Don't understand request type '${request.request}'`);
+      return;
+    }
+
+    // Note that we don't try and guard against multiple repairs being in
+    // progress as we don't do anything too smart that could cause problems,
+    // but just upload items. If we get any smarter we should re-think this
+    // (but when we do, note that checking this._currentState isn't enough as
+    // this responder is not a singleton)
+
+    this._currentState = {
+      request,
+      rawCommand,
+      processedCommand: false,
+      ids: [],
+    };
+
+    try {
+      let engine = this.service.engineManager.get("bookmarks");
+      let { toUpload, toDelete } = await this._fetchItemsToUpload(request);
+
+      if (toUpload.size || toDelete.size) {
+        log.debug(`repair request will upload ${toUpload.size} items and delete ${toDelete.size} items`);
+        // whew - now add these items to the tracker "weakly" (ie, they will not
+        // persist in the case of a restart, but that's OK - we'll then end up here
+        // again) and also record them in the response we send back.
+        for (let id of toUpload) {
+          engine.addForWeakUpload(id);
+          this._currentState.ids.push(id);
+        }
+        for (let id of toDelete) {
+          engine.addForWeakUpload(id, { forceTombstone: true });
+          this._currentState.ids.push(id);
+        }
+
+        // We have arranged for stuff to be uploaded, so wait until that's done.
+        Svc.Obs.add("weave:engine:sync:uploaded", this.onUploaded, this);
+        // and record in telemetry that we got this far - just incase we never
+        // end up doing the upload for some obscure reason.
+        let eventExtra = {
+          flowID: request.flowID,
+          numIDs: this._currentState.ids.length.toString(),
+        };
+        this.service.recordTelemetryEvent("repairResponse", "uploading", undefined, eventExtra);
+      } else {
+        // We were unable to help with the repair, so report that we are done.
+        await this._finishRepair();
+      }
+    } catch (ex) {
+      if (Async.isShutdownException(ex)) {
+        // this repair request will be tried next time.
+        throw ex;
+      }
+      // On failure, we still write a response so the requestor knows to move
+      // on, but we record the failure reason in telemetry.
+      log.error("Failed to respond to the repair request", ex);
+      this._currentState.failureReason = SyncTelemetry.transformError(ex);
+      await this._finishRepair();
+    }
+  }
+
+  async _fetchItemsToUpload(request) {
+    let toUpload = new Set(); // items we will upload.
+    let toDelete = new Set(); // items we will delete.
+
+    let requested = new Set(request.ids);
+
+    let engine = this.service.engineManager.get("bookmarks");
+    // Determine every item that may be impacted by the requested IDs - eg,
+    // this may include children if a requested ID is a folder.
+    // Turn an array of { recordId, syncable } into a map of recordId -> syncable.
+    let repairable = await PlacesSyncUtils.bookmarks.fetchRecordIdsForRepair(request.ids);
+    if (repairable.length == 0) {
+      // server will get upset if we request an empty set, and we can't do
+      // anything in that case, so bail now.
+      return { toUpload, toDelete };
+    }
+
+    // which of these items exist on the server?
+    let itemSource = engine.itemSource();
+    itemSource.ids = repairable.map(item => item.recordId);
+    log.trace(`checking the server for items`, itemSource.ids);
+    let itemsResponse = await itemSource.get();
+    // If the response failed, don't bother trying to parse the output.
+    // Throwing here means we abort the repair, which isn't ideal for transient
+    // errors (eg, no network, 500 service outage etc), but we don't currently
+    // have a sane/safe way to try again later (we'd need to implement a kind
+    // of timeout, otherwise we might end up retrying forever and never remove
+    // our request command.) Bug 1347805.
+    if (!itemsResponse.success) {
+      throw new Error(`request for server IDs failed: ${itemsResponse.status}`);
+    }
+    let existRemotely = new Set(JSON.parse(itemsResponse));
+    // We need to be careful about handing the requested items:
+    // * If the item exists locally but isn't in the tree of items we sync
+    //   (eg, it might be a left-pane item or similar, we write a tombstone.
+    // * If the item exists locally as a folder, we upload the folder and any
+    //   children which don't exist on the server. (Note that we assume the
+    //   parents *do* exist)
+    // Bug 1343101 covers additional issues we might repair in the future.
+    for (let { recordId: id, syncable } of repairable) {
+      if (requested.has(id)) {
+        if (syncable) {
+          log.debug(`repair request to upload item '${id}' which exists locally; uploading`);
+          toUpload.add(id);
+        } else {
+          // explicitly requested and not syncable, so tombstone.
+          log.debug(`repair request to upload item '${id}' but it isn't under a syncable root; writing a tombstone`);
+          toDelete.add(id);
+        }
+      // The item wasn't explicitly requested - only upload if it is syncable
+      // and doesn't exist on the server.
+      } else if (syncable && !existRemotely.has(id)) {
+        log.debug(`repair request found related item '${id}' which isn't on the server; uploading`);
+        toUpload.add(id);
+      } else if (!syncable && existRemotely.has(id)) {
+        log.debug(`repair request found non-syncable related item '${id}' on the server; writing a tombstone`);
+        toDelete.add(id);
+      } else {
+        log.debug(`repair request found related item '${id}' which we will not upload; ignoring`);
+      }
+    }
+    return { toUpload, toDelete };
+  }
+
+  onUploaded(subject, data) {
+    if (data != "bookmarks") {
+      return;
+    }
+    Svc.Obs.remove("weave:engine:sync:uploaded", this.onUploaded, this);
+    if (subject.failed) {
+      return;
+    }
+    log.debug(`bookmarks engine has uploaded stuff - creating a repair response`, subject);
+    Async.promiseSpinningly(this._finishRepair());
+  }
+
+  async _finishRepair() {
+    let clientsEngine = this.service.clientsEngine;
+    let flowID = this._currentState.request.flowID;
+    let response = {
+      request: this._currentState.request.request,
+      collection: "bookmarks",
+      clientID: clientsEngine.localID,
+      flowID,
+      ids: this._currentState.ids,
+    };
+    let clientID = this._currentState.request.requestor;
+    await clientsEngine.sendCommand("repairResponse", [response], clientID, { flowID });
+    // and nuke the request from our client.
+    await clientsEngine.removeLocalCommand(this._currentState.rawCommand);
+    let eventExtra = {
+      flowID,
+      numIDs: response.ids.length.toString(),
+    };
+    if (this._currentState.failureReason) {
+      // *sob* - recording this in "extra" means the value must be a string of
+      // max 85 chars.
+      eventExtra.failureReason = JSON.stringify(this._currentState.failureReason).substring(0, 85);
+      this.service.recordTelemetryEvent("repairResponse", "failed", undefined, eventExtra);
+    } else {
+      this.service.recordTelemetryEvent("repairResponse", "finished", undefined, eventExtra);
+    }
+    this._currentState = null;
+  }
+
+  async _abortRepair(request, rawCommand, why) {
+    log.warn(`aborting repair request: ${why}`);
+    await this.service.clientsEngine.removeLocalCommand(rawCommand);
+    // record telemetry for this.
+    let eventExtra = {
+      flowID: request.flowID,
+      reason: why,
+    };
+    this.service.recordTelemetryEvent("repairResponse", "aborted", undefined, eventExtra);
+    // We could also consider writing a response here so the requestor can take
+    // some immediate action rather than timing out, but we abort only in cases
+    // that should be rare, so let's wait and see what telemetry tells us.
+  }
+}
+
+/* Exposed in case another module needs to understand our state.
+*/
+BookmarkRepairRequestor.STATE = STATE;
+BookmarkRepairRequestor.PREF = PREF;
rename from services/sync/modules/bookmark_validator.js
rename to browser/extensions/sync-repair/bookmark_validator.jsm
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/bootstrap.js
@@ -0,0 +1,77 @@
+/* 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";
+
+/* exported startup, shutdown, install, uninstall */
+
+const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.importGlobalProperties(["fetch"]);
+
+XPCOMUtils.defineLazyModuleGetter(this, "Services", "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Weave", "resource://services-sync/main.js");
+XPCOMUtils.defineLazyModuleGetter(this, "RepairManager", "resource://sync-repair/repair_manager.jsm");
+
+const Loader = {
+  // Used to handle the case where we unregister before whenLoaded() from a
+  // previous call to `register()` fires.
+  _pendingPromise: Promise.resolve(),
+
+  // This is done so that we can unload components loaded with Cu.import, so
+  // that we can properly update without restart.
+  _jsms: new Set(),
+  async _populateJSMs() {
+    // This is a bit awkward, but it gets a listing of the resource
+    // folder, and uses it to find out what we'll need to unload later.
+    let dirListText = await fetch("resource://sync-repair/").then(r => r.text());
+    // First two lines are headings.
+    let dirList = dirListText.split("\n").slice(2);
+    // Each line is more or less in the format `num: filename.ext size timestamp type`
+    // we only care about the filename portion, and only if it is a JSM.
+    let jsmFilenames = dirList.map(x => x.split(" ")[1]).filter(x => x && x.match(/\.jsm$/i));
+    this._jsms = new Set(jsmFilenames.map(filename => `resource://sync-repair/${filename}`));
+  },
+
+  _unloadJSMs() {
+    for (let jsm of this._jsms) {
+      Cu.unload(jsm);
+    }
+  },
+
+  register() {
+    return this._pendingPromise = this._pendingPromise
+      .then(() => this._whenSyncLoaded())
+      .then(() => this._populateJSMs())
+      .then(() => Weave.Service.setRepairManager(new RepairManager()))
+      .catch(Cu.reportError);
+  },
+
+  unregister() {
+    return this._pendingPromise = this._pendingPromise
+      .then(() => this._whenSyncLoaded())
+      .then(() => Weave.Service.clearRepairManager())
+      .then(() => this._unloadJSMs())
+      .catch(Cu.reportError);
+  },
+
+  _whenSyncLoaded() {
+    return Cc["@mozilla.org/weave/service;1"]
+           .getService(Ci.nsISupports)
+           .wrappedJSObject
+           .whenLoaded();
+  },
+};
+
+function startup(data) {
+  Loader.register();
+}
+
+function shutdown() {
+  Loader.unregister();
+}
+
+function install() {}
+function uninstall() {}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/collection_repair.jsm
@@ -0,0 +1,154 @@
+/* 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";
+
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://services-sync/main.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "BookmarkRepairRequestor",
+  "resource://services-sync/bookmark_repair.jsm");
+
+this.EXPORTED_SYMBOLS = ["getRepairRequestor", "getAllRepairRequestors",
+                         "CollectionRepairRequestor",
+                         "getRepairResponder",
+                         "CollectionRepairResponder",
+                         "getValidator"];
+
+// The individual requestors/responders/validators, lazily loaded.
+const REQUESTORS = {
+  bookmarks: ["bookmark_repair.jsm", "BookmarkRepairRequestor"],
+};
+
+const RESPONDERS = {
+  bookmarks: ["bookmark_repair.jsm", "BookmarkRepairResponder"],
+};
+
+const VALIDATORS = {
+  addons: ["addon_validator.jsm", "AddonValidator"],
+  bookmarks: ["bookmark_validator.jsm", "BookmarkValidator"],
+  forms: ["form_validator.jsm", "FormValidator"],
+  passwords: ["password_validator.jsm", "PasswordValidator"],
+};
+
+function _getRepairConstructor(which, collection) {
+  if (!(collection in which)) {
+    return null;
+  }
+  let [modname, symbolname] = which[collection];
+  let ns = {};
+  Cu.import("resource://sync-repair/" + modname, ns);
+  return ns[symbolname];
+}
+
+function getRepairRequestor(collection) {
+  let ctor = _getRepairConstructor(REQUESTORS, collection);
+  if (!ctor) {
+    return null;
+  }
+  return new ctor();
+}
+
+function getAllRepairRequestors() {
+  let result = {};
+  for (let collection of Object.keys(REQUESTORS)) {
+    let ctor = _getRepairConstructor(REQUESTORS, collection);
+    result[collection] = new ctor();
+  }
+  return result;
+}
+
+function getRepairResponder(collection) {
+  let ctor = _getRepairConstructor(RESPONDERS, collection);
+  if (!ctor) {
+    return null;
+  }
+  return new ctor();
+}
+
+function getValidator(collection) {
+  let ctor = _getRepairConstructor(VALIDATORS, collection);
+  if (!ctor) {
+    return null;
+  }
+  return new ctor();
+}
+
+// The abstract classes.
+class CollectionRepairRequestor {
+  constructor(service = null) {
+    // allow service to be mocked in tests.
+    this.service = service || Weave.Service;
+  }
+
+  /* Try to resolve some issues with the server without involving other clients.
+     Returns true if we repaired some items.
+
+     @param   validationInfo       {Object}
+              The validation info as returned by the collection's validator.
+
+  */
+  tryServerOnlyRepairs(validationInfo) {
+    return false;
+  }
+
+  /* See if the repairer is willing and able to begin a repair process given
+     the specified validation information.
+     Returns true if a repair was started and false otherwise.
+
+     @param   validationInfo       {Object}
+              The validation info as returned by the collection's validator.
+
+     @param   flowID               {String}
+              A guid that uniquely identifies this repair process for this
+              collection, and which should be sent to any requestors and
+              reported in telemetry.
+
+  */
+  async startRepairs(validationInfo, flowID) {
+    throw new Error("not implemented");
+  }
+
+  /* Work out what state our current repair request is in, and whether it can
+     proceed to a new state.
+     Returns true if we could continue the repair - even if the state didn't
+     actually move. Returns false if we aren't actually repairing.
+
+     @param   responseInfo       {Object}
+              An optional response to a previous repair request, as returned
+              by a remote repair responder.
+
+  */
+  async continueRepairs(responseInfo = null) {
+    throw new Error("not implemented");
+  }
+}
+
+class CollectionRepairResponder {
+  constructor(service = null) {
+    // allow service to be mocked in tests.
+    this.service = service || Weave.Service;
+  }
+
+  /* Take some action in response to a repair request. Returns a promise that
+     resolves once the repair process has started, or rejects if there
+     was an error starting the repair.
+
+     Note that when the promise resolves the repair is not yet complete - at
+     some point in the future the repair will auto-complete, at which time
+     |rawCommand| will be removed from the list of client commands for this
+     client.
+
+     @param   request       {Object}
+              The repair request as sent by another client.
+
+     @param   rawCommand    {Object}
+              The command object as stored in the clients engine, and which
+              will be automatically removed once a repair completes.
+  */
+  async repair(request, rawCommand) {
+    throw new Error("not implemented");
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/collection_validator.jsm
@@ -0,0 +1,233 @@
+/* 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";
+
+const Cu = Components.utils;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Async",
+                                  "resource://services-common/async.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Weave", "resource://services-sync/main.js");
+
+this.EXPORTED_SYMBOLS = ["CollectionValidator", "CollectionProblemData"];
+
+class CollectionProblemData {
+  constructor() {
+    this.missingIDs = 0;
+    this.duplicates = [];
+    this.clientMissing = [];
+    this.serverMissing = [];
+    this.serverDeleted = [];
+    this.serverUnexpected = [];
+    this.differences = [];
+  }
+
+  /**
+   * Produce a list summarizing problems found. Each entry contains {name, count},
+   * where name is the field name for the problem, and count is the number of times
+   * the problem was encountered.
+   *
+   * Validation has failed if all counts are not 0.
+   */
+  getSummary() {
+    return [
+      { name: "clientMissing", count: this.clientMissing.length },
+      { name: "serverMissing", count: this.serverMissing.length },
+      { name: "serverDeleted", count: this.serverDeleted.length },
+      { name: "serverUnexpected", count: this.serverUnexpected.length },
+      { name: "differences", count: this.differences.length },
+      { name: "missingIDs", count: this.missingIDs },
+      { name: "duplicates", count: this.duplicates.length }
+    ];
+  }
+}
+
+class CollectionValidator {
+  // Construct a generic collection validator. This is intended to be called by
+  // subclasses.
+  // - name: Name of the engine
+  // - idProp: Property that identifies a record. That is, if a client and server
+  //   record have the same value for the idProp property, they should be
+  //   compared against eachother.
+  // - props: Array of properties that should be compared
+  constructor(name, idProp, props) {
+    this.name = name;
+    this.props = props;
+    this.idProp = idProp;
+
+    // This property deals with the fact that form history records are never
+    // deleted from the server. The FormValidator subclass needs to ignore the
+    // client missing records, and it uses this property to achieve it -
+    // (Bug 1354016).
+    this.ignoresMissingClients = false;
+    this.engine = Weave.Service.engineManager.get(name);
+  }
+
+  // Should a custom ProblemData type be needed, return it here.
+  emptyProblemData() {
+    return new CollectionProblemData();
+  }
+
+  async getServerItems(engine) {
+    let collection = engine.itemSource();
+    let collectionKey = engine.service.collectionKeys.keyForCollection(engine.name);
+    collection.full = true;
+    let result = await collection.getBatched();
+    if (!result.response.success) {
+      throw result.response;
+    }
+    let maybeYield = Async.jankYielder();
+    let cleartexts = [];
+    for (let record of result.records) {
+      await maybeYield();
+      await record.decrypt(collectionKey);
+      cleartexts.push(record.cleartext);
+    }
+    return cleartexts;
+  }
+
+  // Should return a promise that resolves to an array of client items.
+  getClientItems() {
+    return Promise.reject("Must implement");
+  }
+
+  /**
+   * Can we guarantee validation will fail with a reason that isn't actually a
+   * problem? For example, if we know there are pending changes left over from
+   * the last sync, this should resolve to false. By default resolves to true.
+   */
+  async canValidate() {
+    return true;
+  }
+
+  // Turn the client item into something that can be compared with the server item,
+  // and is also safe to mutate.
+  normalizeClientItem(item) {
+    return Cu.cloneInto(item, {});
+  }
+
+  // Turn the server item into something that can be easily compared with the client
+  // items.
+  async normalizeServerItem(item) {
+    return item;
+  }
+
+  // Return whether or not a server item should be present on the client. Expected
+  // to be overridden.
+  clientUnderstands(item) {
+    return true;
+  }
+
+  // Return whether or not a client item should be present on the server. Expected
+  // to be overridden
+  syncedByClient(item) {
+    return true;
+  }
+
+  // Compare the server item and the client item, and return a list of property
+  // names that are different. Can be overridden if needed.
+  getDifferences(client, server) {
+    let differences = [];
+    for (let prop of this.props) {
+      let clientProp = client[prop];
+      let serverProp = server[prop];
+      if ((clientProp || "") !== (serverProp || "")) {
+        differences.push(prop);
+      }
+    }
+    return differences;
+  }
+
+  // Returns an object containing
+  //   problemData: an instance of the class returned by emptyProblemData(),
+  //   clientRecords: Normalized client records
+  //   records: Normalized server records,
+  //   deletedRecords: Array of ids that were marked as deleted by the server.
+  async compareClientWithServer(clientItems, serverItems) {
+    let maybeYield = Async.jankYielder();
+    const clientRecords = [];
+    for (let item of clientItems) {
+      await maybeYield();
+      clientRecords.push(this.normalizeClientItem(item));
+    }
+    const serverRecords = [];
+    for (let item of serverItems) {
+      await maybeYield();
+      serverRecords.push((await this.normalizeServerItem(item)));
+    }
+    let problems = this.emptyProblemData();
+    let seenServer = new Map();
+    let serverDeleted = new Set();
+    let allRecords = new Map();
+
+    for (let record of serverRecords) {
+      let id = record[this.idProp];
+      if (!id) {
+        ++problems.missingIDs;
+        continue;
+      }
+      if (record.deleted) {
+        serverDeleted.add(record);
+      } else {
+        let possibleDupe = seenServer.get(id);
+        if (possibleDupe) {
+          problems.duplicates.push(id);
+        } else {
+          seenServer.set(id, record);
+          allRecords.set(id, { server: record, client: null, });
+        }
+        record.understood = this.clientUnderstands(record);
+      }
+    }
+
+    let seenClient = new Map();
+    for (let record of clientRecords) {
+      let id = record[this.idProp];
+      record.shouldSync = this.syncedByClient(record);
+      seenClient.set(id, record);
+      let combined = allRecords.get(id);
+      if (combined) {
+        combined.client = record;
+      } else {
+        allRecords.set(id, { client: record, server: null });
+      }
+    }
+
+    for (let [id, { server, client }] of allRecords) {
+      if (!client && !server) {
+        throw new Error("Impossible: no client or server record for " + id);
+      } else if (server && !client) {
+        if (!this.ignoresMissingClients && server.understood) {
+          problems.clientMissing.push(id);
+        }
+      } else if (client && !server) {
+        if (client.shouldSync) {
+          problems.serverMissing.push(id);
+        }
+      } else {
+        if (!client.shouldSync) {
+          if (!problems.serverUnexpected.includes(id)) {
+            problems.serverUnexpected.push(id);
+          }
+          continue;
+        }
+        let differences = this.getDifferences(client, server);
+        if (differences && differences.length) {
+          problems.differences.push({ id, differences });
+        }
+      }
+    }
+    return {
+      problemData: problems,
+      clientRecords,
+      records: serverRecords,
+      deletedRecords: [...serverDeleted]
+    };
+  }
+}
+
+// Default to 0, some engines may override.
+CollectionValidator.prototype.version = 0;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/doctor.jsm
@@ -0,0 +1,273 @@
+/* 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/. */
+
+// A doctor for our collections. It can be asked to make a consultation, and
+// may just diagnose an issue without attempting to cure it, may diagnose and
+// attempt to cure, or may decide it is overworked and underpaid.
+// Or something - naming is hard :)
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["Doctor"];
+
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://services-common/async.js");
+Cu.import("resource://services-common/observers.js");
+Cu.import("resource://services-sync/service.js");
+Cu.import("resource://services-sync/resource.js");
+Cu.import("resource://services-sync/util.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "getRepairRequestor",
+  "resource://sync-repair/collection_repair.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "getAllRepairRequestors",
+  "resource://sync-repair/collection_repair.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "getValidator",
+  "resource://sync-repair/collection_repair.jsm");
+
+const log = Log.repository.getLogger("Sync.Repair.Doctor");
+
+this.REPAIR_ADVANCE_PERIOD = 86400; // 1 day
+
+const ValidatedEngines = [
+  { name: "bookmarks", symbol: "BookmarkValidator", module: "resource://bookmark_validator.jsm" }
+];
+
+this.Doctor = {
+  anyClientsRepairing(service, collection, ignoreFlowID = null) {
+    if (!service || !service.clientsEngine) {
+      log.info("Missing clients engine, assuming we're in test code");
+      return false;
+    }
+    return service.clientsEngine.remoteClients.some(client =>
+      client.commands && client.commands.some(command => {
+        if (command.command != "repairResponse" && command.command != "repairRequest") {
+          return false;
+        }
+        if (!command.args || command.args.length != 1) {
+          return false;
+        }
+        if (command.args[0].collection != collection) {
+          return false;
+        }
+        if (ignoreFlowID != null && command.args[0].flowID == ignoreFlowID) {
+          return false;
+        }
+        return true;
+      })
+    );
+  },
+
+  async consult(recentlySyncedEngines) {
+    if (!Services.telemetry.canRecordBase) {
+      log.info("Skipping consultation: telemetry reporting is disabled");
+      return;
+    }
+
+    let engineInfos = this._getEnginesToValidate(recentlySyncedEngines);
+
+    await this._runValidators(engineInfos);
+
+    // We are called at the end of a sync, which is a good time to periodically
+    // check each repairer to see if it can advance.
+    if (this._now() - this.lastRepairAdvance > REPAIR_ADVANCE_PERIOD) {
+      try {
+        for (let [collection, requestor] of Object.entries(this._getAllRepairRequestors())) {
+          try {
+            let advanced = await requestor.continueRepairs();
+            log.info(`${collection} reparier ${advanced ? "advanced" : "did not advance"}.`);
+          } catch (ex) {
+            if (Async.isShutdownException(ex)) {
+              throw ex;
+            }
+            log.error(`${collection} repairer failed`, ex);
+          }
+        }
+      } finally {
+        this.lastRepairAdvance = Math.floor(this._now());
+      }
+    }
+  },
+
+  _getEnginesToValidate(recentlySyncedEngines) {
+    let result = {};
+    for (let e of recentlySyncedEngines) {
+      let prefPrefix = `engine.${e.name}.`;
+      if (!Svc.Prefs.get(prefPrefix + "validation.enabled", false)) {
+        log.info(`Skipping check of ${e.name} - disabled via preferences`);
+        continue;
+      }
+      // Check the last validation time for the engine.
+      let lastValidation = Svc.Prefs.get(prefPrefix + "validation.lastTime", 0);
+      let validationInterval = Svc.Prefs.get(prefPrefix + "validation.interval");
+      let nowSeconds = this._now();
+
+      if (nowSeconds - lastValidation < validationInterval) {
+        log.info(`Skipping validation of ${e.name}: too recent since last validation attempt`);
+        continue;
+      }
+      // Update the time now, even if we decline to actually perform a
+      // validation. We don't want to check the rest of these more frequently
+      // than once a day.
+      Svc.Prefs.set(prefPrefix + "validation.lastTime", Math.floor(nowSeconds));
+
+      // Validation only occurs a certain percentage of the time.
+      let validationProbability = Svc.Prefs.get(prefPrefix + "validation.percentageChance", 0) / 100.0;
+      if (validationProbability < Math.random()) {
+        log.info(`Skipping validation of ${e.name}: Probability threshold not met`);
+        continue;
+      }
+
+      let maxRecords = Svc.Prefs.get(prefPrefix + "validation.maxRecords");
+      if (!maxRecords) {
+        log.info(`Skipping validation of ${e.name}: No maxRecords specified`);
+        continue;
+      }
+      // OK, so this is a candidate - the final decision will be based on the
+      // number of records actually found.
+      result[e.name] = { engine: e, maxRecords };
+    }
+    return result;
+  },
+
+  async _runValidators(engineInfos) {
+    if (Object.keys(engineInfos).length == 0) {
+      log.info("Skipping validation: no engines qualify");
+      return;
+    }
+
+    if (Object.values(engineInfos).filter(i => i.maxRecords != -1).length != 0) {
+      // at least some of the engines have maxRecord restrictions which require
+      // us to ask the server for the counts.
+      let countInfo = await this._fetchCollectionCounts();
+      for (let [engineName, recordCount] of Object.entries(countInfo)) {
+        if (engineName in engineInfos) {
+          engineInfos[engineName].recordCount = recordCount;
+        }
+      }
+    }
+
+    for (let [engineName, { engine, maxRecords, recordCount }] of Object.entries(engineInfos)) {
+      // maxRecords of -1 means "any number", so we can skip asking the server.
+      // Used for tests.
+      if (maxRecords >= 0 && recordCount > maxRecords) {
+        log.debug(`Skipping validation for ${engineName} because ` +
+                        `the number of records (${recordCount}) is greater ` +
+                        `than the maximum allowed (${maxRecords}).`);
+        continue;
+      }
+      let validator = this._getValidator(engine.name);
+      if (!validator) {
+        continue;
+      }
+
+      if (!await validator.canValidate()) {
+        log.debug(`Skipping validation for ${engineName} because validator.canValidate() is false`);
+        continue;
+      }
+
+      // Let's do it!
+      Services.console.logStringMessage(
+        `Sync is about to run a consistency check of ${engine.name}. This may be slow, and ` +
+        `can be controlled using the pref "services.sync.${engine.name}.validation.enabled".\n` +
+        `If you encounter any problems because of this, please file a bug.`);
+
+      // Make a new flowID just incase we end up starting a repair.
+      let flowID = Utils.makeGUID();
+      try {
+        // XXX - must get the flow ID to either the validator, or directly to
+        // telemetry. I guess it's probably OK to always record a flowID even
+        // if we don't end up repairing?
+        log.info(`Running validator for ${engine.name}`);
+        let result = await validator.validate(engine);
+        Observers.notify("weave:engine:validate:finish", result, engine.name);
+        // And see if we should repair.
+        await this._maybeCure(engine, result, flowID);
+      } catch (ex) {
+        if (Async.isShutdownException(ex)) {
+          throw ex;
+        }
+        log.error(`Failed to run validation on ${engine.name}!`, ex);
+        Observers.notify("weave:engine:validate:error", ex, engine.name);
+        // Keep validating -- there's no reason to think that a failure for one
+        // validator would mean the others will fail.
+      }
+    }
+  },
+
+  // Separated to allow easier mocking during tests
+  _getValidator(name) {
+    return getValidator(name);
+  },
+
+  async _maybeCure(engine, validationResults, flowID) {
+    if (!this._shouldRepair(engine)) {
+      log.info(`Skipping repair of ${engine.name} - disabled via preferences`);
+      return;
+    }
+
+    let requestor = this._getRepairRequestor(engine.name);
+    let didStart = false;
+    if (requestor) {
+      if (requestor.tryServerOnlyRepairs(validationResults)) {
+        return; // TODO: It would be nice if we could request a validation to be
+                // done on next sync.
+      }
+      didStart = await requestor.startRepairs(validationResults, flowID);
+    }
+    log.info(`${didStart ? "did" : "didn't"} start a repair of ${engine.name} with flowID ${flowID}`);
+  },
+
+  _shouldRepair(engine) {
+    return Svc.Prefs.get(`engine.${engine.name}.repair.enabled`, false);
+  },
+
+  // mainly for mocking.
+  async _fetchCollectionCounts() {
+    let collectionCountsURL = Service.userBaseURL + "info/collection_counts";
+    try {
+      let infoResp = await Service._fetchInfo(collectionCountsURL);
+      if (!infoResp.success) {
+        log.error("Can't fetch collection counts: request to info/collection_counts responded with "
+                        + infoResp.status);
+        return {};
+      }
+      return infoResp.obj; // might throw because obj is a getter which parses json.
+    } catch (ex) {
+      if (Async.isShutdownException(ex)) {
+        throw ex;
+      }
+      // Not running validation is totally fine, so we just write an error log and return.
+      log.error("Caught error when fetching counts", ex);
+      return {};
+    }
+  },
+
+  get lastRepairAdvance() {
+    return Svc.Prefs.get("doctor.lastRepairAdvance", 0);
+  },
+
+  set lastRepairAdvance(value) {
+    Svc.Prefs.set("doctor.lastRepairAdvance", value);
+  },
+
+  // functions used so tests can mock them
+  _now() {
+    // We use the server time, which is SECONDS
+    return Resource.serverTime;
+  },
+
+  _getRepairRequestor(name) {
+    return getRepairRequestor(name);
+  },
+
+  _getAllRepairRequestors() {
+    return getAllRepairRequestors();
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/form_validator.jsm
@@ -0,0 +1,67 @@
+/* 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";
+
+var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://sync-repair/collection_validator.jsm");
+Cu.import("resource://services-sync/engines/forms.js");
+
+this.EXPORTED_SYMBOLS = ["FormValidator"];
+
+class FormsProblemData extends CollectionProblemData {
+  getSummary() {
+    // We don't support syncing deleted form data, so "clientMissing" isn't a problem
+    return super.getSummary().filter(entry =>
+      entry.name !== "clientMissing");
+  }
+}
+
+class FormValidator extends CollectionValidator {
+  constructor() {
+    super("forms", "id", ["name", "value"]);
+    this.ignoresMissingClients = true;
+  }
+
+  emptyProblemData() {
+    return new FormsProblemData();
+  }
+
+  async getClientItems() {
+    return this.engine._search(["guid", "fieldname", "value"], {});
+  }
+
+  normalizeClientItem(item) {
+    return {
+      id: item.guid,
+      guid: item.guid,
+      name: item.fieldname,
+      fieldname: item.fieldname,
+      value: item.value,
+      original: item,
+    };
+  }
+
+  async normalizeServerItem(item) {
+    let res = Object.assign({
+      guid: item.id,
+      fieldname: item.name,
+      original: item,
+    }, item);
+    // Missing `name` or `value` causes the getGUID call to throw
+    if (item.name !== undefined && item.value !== undefined) {
+      let guid = await this.engine._getGUID(item.name, item.value);
+      if (guid) {
+        res.guid = guid;
+        res.id = guid;
+        res.duped = true;
+      }
+    }
+
+    return res;
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/install.rdf.in
@@ -0,0 +1,29 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+#filter substitution
+
+<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+     xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+
+  <Description about="urn:mozilla:install-manifest">
+    <em:name>Sync Validation and Repair</em:name>
+    <em:id>sync-repair@mozilla.org</em:id>
+    <em:version>1.0</em:version>
+    <em:type>2</em:type>
+    <em:bootstrap>true</em:bootstrap>
+    <em:multiprocessCompatible>true</em:multiprocessCompatible>
+
+    <!-- Target Application this extension can install into,
+        with minimum and maximum supported versions. -->
+    <em:targetApplication>
+      <Description>
+        <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
+        <em:minVersion>@MOZ_APP_VERSION@</em:minVersion>
+        <em:maxVersion>@MOZ_APP_MAXVERSION@</em:maxVersion>
+      </Description>
+    </em:targetApplication>
+  </Description>
+</RDF>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/jar.mn
@@ -0,0 +1,8 @@
+# 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/.
+
+# Using jsm files allows us to more easily glob these here
+[features/sync-repair@mozilla.org] chrome.jar:
+% resource sync-repair %res/
+  res/ (*.jsm)
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/moz.build
@@ -0,0 +1,23 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DEFINES['MOZ_APP_VERSION'] = CONFIG['MOZ_APP_VERSION']
+DEFINES['MOZ_APP_MAXVERSION'] = CONFIG['MOZ_APP_MAXVERSION']
+
+FINAL_TARGET_FILES.features['sync-repair@mozilla.org'] += [
+  'bootstrap.js'
+]
+
+FINAL_TARGET_PP_FILES.features['sync-repair@mozilla.org'] += [
+  'install.rdf.in'
+]
+
+XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
+
+JAR_MANIFESTS += ['jar.mn']
+
+with Files('**'):
+    BUG_COMPONENT = ('Firefox', 'Sync')
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/password_validator.jsm
@@ -0,0 +1,57 @@
+/* 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";
+
+var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://services-sync/util.js");
+Cu.import("resource://sync-repair/collection_validator.jsm");
+
+this.EXPORTED_SYMBOLS = ["PasswordValidator"];
+
+class PasswordValidator extends CollectionValidator {
+  constructor() {
+    super("passwords", "id", [
+      "hostname",
+      "formSubmitURL",
+      "httpRealm",
+      "password",
+      "passwordField",
+      "username",
+      "usernameField",
+    ]);
+  }
+
+  getClientItems() {
+    let logins = Services.logins.getAllLogins({});
+    let syncHosts = Utils.getSyncCredentialsHosts();
+    let result = logins.map(l => l.QueryInterface(Ci.nsILoginMetaInfo))
+                       .filter(l => !syncHosts.has(l.hostname));
+    return Promise.resolve(result);
+  }
+
+  normalizeClientItem(item) {
+    return {
+      id: item.guid,
+      guid: item.guid,
+      hostname: item.hostname,
+      formSubmitURL: item.formSubmitURL,
+      httpRealm: item.httpRealm,
+      password: item.password,
+      passwordField: item.passwordField,
+      username: item.username,
+      usernameField: item.usernameField,
+      original: item,
+    };
+  }
+
+  async normalizeServerItem(item) {
+    return Object.assign({ guid: item.id }, item);
+  }
+}
+
+
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/repair_manager.jsm
@@ -0,0 +1,56 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = ["RepairManager"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Log", "resource://gre/modules/Log.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Weave", "resource://services-sync/main.js");
+XPCOMUtils.defineLazyModuleGetter(this, "Doctor", "resource://sync-repair/doctor.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "getRepairRequestor",
+  "resource://sync-repair/collection_repair.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "getRepairResponder",
+  "resource://sync-repair/collection_repair.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+  return Log.repository.getLogger("Sync.Repair.Manager");
+});
+
+class RepairManager {
+  constructor() {
+    log.info("Repair manager initialized");
+  }
+
+  async consultDoctor(engines) {
+    return Doctor.consult(engines);
+  }
+
+  async handleRepairReponse(response, rawCommand) {
+    let requestor = getRepairRequestor(response.collection);
+    if (!requestor) {
+      log.warn("repairResponse for unknown collection", response);
+      return;
+    }
+    if (!(await requestor.continueRepairs(response))) {
+      log.warn("repairResponse couldn't continue the repair", response);
+    }
+  }
+
+  async handleRepairRequest(request, rawCommand) {
+    // Another device has sent us a request to make some repair.
+    let responder = getRepairResponder(request.collection);
+    if (!responder) {
+      log.warn("repairRequest for unknown collection", request);
+      return false;
+    }
+    return responder.repair(request, rawCommand);
+  }
+}
+
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/test/unit/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+  "extends": [
+    "plugin:mozilla/xpcshell-test",
+  ],
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/test/unit/head.js
@@ -0,0 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from ../../../../../services/sync/tests/unit/head_appinfo.js */
+/* import-globals-from ../../../../../services/common/tests/unit/head_helpers.js */
+/* import-globals-from ../../../../../services/sync/tests/unit/head_helpers.js */
+/* import-globals-from ../../../../../services/sync/tests/unit/head_http_server.js */
+
+Components.utils.import("resource://gre/modules/Services.jsm");
+
+(function() {
+  // Load our bootstrap extension manifest so we can access our chrome/resource URIs.
+  const EXTENSION_ID = "sync-repair@mozilla.org";
+  let extensionDir = Services.dirsvc.get("GreD", Components.interfaces.nsIFile);
+  extensionDir.append("browser");
+  extensionDir.append("features");
+  extensionDir.append(EXTENSION_ID);
+  Components.manager.addBootstrappedManifestLocation(extensionDir);
+}());
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/test/unit/test_bookmark_repair.js
@@ -0,0 +1,522 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from head.js */
+
+
+// Tests the bookmark repair requestor and responder end-to-end (ie, without
+// many mocks)
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://services-sync/constants.js");
+Cu.import("resource://services-sync/service.js");
+Cu.import("resource://services-sync/engines/clients.js");
+Cu.import("resource://services-sync/engines/bookmarks.js");
+Cu.import("resource://testing-common/services/sync/utils.js");
+
+Cu.import("resource://sync-repair/repair_manager.jsm");
+Cu.import("resource://sync-repair/bookmark_repair.jsm");
+Cu.import("resource://sync-repair/doctor.jsm");
+
+const LAST_BOOKMARK_SYNC_PREFS = [
+  "bookmarks.lastSync",
+  "bookmarks.lastSyncLocal",
+];
+
+const BOOKMARK_REPAIR_STATE_PREFS = [
+  "client.GUID",
+  "doctor.lastRepairAdvance",
+  ...LAST_BOOKMARK_SYNC_PREFS,
+  ...Object.values(BookmarkRepairRequestor.PREF).map(name =>
+    `repairs.bookmarks.${name}`
+  ),
+];
+
+let clientsEngine;
+let bookmarksEngine;
+var recordedEvents = [];
+
+add_task(async function setup() {
+  clientsEngine = Service.clientsEngine;
+  clientsEngine.ignoreLastModifiedOnProcessCommands = true;
+  bookmarksEngine = Service.engineManager.get("bookmarks");
+  // Service.setRepairManager(new RepairManager());
+
+  await generateNewKeys(Service.collectionKeys);
+
+  Service.recordTelemetryEvent = (object, method, value, extra = undefined) => {
+    recordedEvents.push({ object, method, value, extra });
+  };
+
+  initTestLogging("Trace");
+  Log.repository.getLogger("Sync.Engine.Bookmarks").level = Log.Level.Trace;
+  Log.repository.getLogger("Sync.Engine.Clients").level = Log.Level.Trace;
+  Log.repository.getLogger("Sqlite").level = Log.Level.Info; // less noisy
+});
+
+function checkRecordedEvents(expected, message) {
+  deepEqual(recordedEvents, expected, message);
+  // and clear the list so future checks are easier to write.
+  recordedEvents = [];
+}
+
+// Backs up and resets all preferences to their default values. Returns a
+// function that restores the preferences when called.
+function backupPrefs(names) {
+  let state = new Map();
+  for (let name of names) {
+    state.set(name, Svc.Prefs.get(name));
+    Svc.Prefs.reset(name);
+  }
+  return () => {
+    for (let [name, value] of state) {
+      Svc.Prefs.set(name, value);
+    }
+  };
+}
+
+async function promiseValidationDone(expected) {
+  // wait for a validation to complete.
+  let obs = promiseOneObserver("weave:engine:validate:finish");
+  let { subject: validationResult } = await obs;
+  // check the results - anything non-zero is checked against |expected|
+  let summary = validationResult.problems.getSummary();
+  let actual = summary.filter(({name, count}) => count);
+  actual.sort((a, b) => String(a.name).localeCompare(b.name));
+  expected.sort((a, b) => String(a.name).localeCompare(b.name));
+  deepEqual(actual, expected);
+}
+
+async function cleanup(server) {
+  await bookmarksEngine._store.wipe();
+  await clientsEngine._store.wipe();
+  Svc.Prefs.resetBranch("");
+  Service.recordManager.clearCache();
+  await promiseStopServer(server);
+}
+
+add_task(async function test_bookmark_repair_integration() {
+  forceBookmarkValidation();
+
+  _("Ensure that a validation error triggers a repair request.");
+
+  let server = await serverForFoo(bookmarksEngine);
+  await SyncTestingInfrastructure(server);
+
+  let user = server.user("foo");
+
+  let initialID = Service.clientsEngine.localID;
+  let remoteID = Utils.makeGUID();
+  try {
+
+    _("Syncing to initialize crypto etc.");
+    await Service.sync();
+
+    _("Create remote client record");
+    server.insertWBO("foo", "clients", new ServerWBO(remoteID, encryptPayload({
+      id: remoteID,
+      name: "Remote client",
+      type: "desktop",
+      commands: [],
+      version: "54",
+      protocols: ["1.5"],
+    }), Date.now() / 1000));
+
+    _("Create bookmark and folder");
+    let folderInfo = await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      title: "Folder 1",
+    });
+    let bookmarkInfo = await PlacesUtils.bookmarks.insert({
+      parentGuid: folderInfo.guid,
+      url: "http://getfirefox.com/",
+      title: "Get Firefox!",
+    });
+
+    _(`Upload ${folderInfo.guid} and ${bookmarkInfo.guid} to server`);
+    let validationPromise = promiseValidationDone([]);
+    await Service.sync();
+    equal(clientsEngine.stats.numClients, 2, "Clients collection should have 2 records");
+    await validationPromise;
+    checkRecordedEvents([], "Should not start repair after first sync");
+
+    _("Back up last sync timestamps for remote client");
+    let restoreRemoteLastBookmarkSync = backupPrefs(LAST_BOOKMARK_SYNC_PREFS);
+
+    _(`Delete ${bookmarkInfo.guid} locally and on server`);
+    // Now we will reach into the server and hard-delete the bookmark
+    user.collection("bookmarks").remove(bookmarkInfo.guid);
+    // And delete the bookmark, but cheat by telling places that Sync did
+    // it, so we don't end up with a tombstone.
+    await PlacesUtils.bookmarks.remove(bookmarkInfo.guid, {
+      source: PlacesUtils.bookmarks.SOURCE_SYNC,
+    });
+    deepEqual((await bookmarksEngine.pullNewChanges()), {},
+      `Should not upload tombstone for ${bookmarkInfo.guid}`);
+
+    // sync again - we should have a few problems...
+    _("Sync again to trigger repair");
+    validationPromise = promiseValidationDone([
+      {"name": "missingChildren", "count": 1},
+      {"name": "structuralDifferences", "count": 1},
+    ]);
+    await Service.sync();
+    await validationPromise;
+    let flowID = Svc.Prefs.get("repairs.bookmarks.flowID");
+    checkRecordedEvents([{
+      object: "repair",
+      method: "started",
+      value: undefined,
+      extra: {
+        flowID,
+        numIDs: "2",
+      },
+    }, {
+      object: "sendcommand",
+      method: "repairRequest",
+      value: undefined,
+      extra: {
+        flowID,
+        deviceID: Service.identity.hashedDeviceID(remoteID),
+      },
+    }, {
+      object: "repair",
+      method: "request",
+      value: "upload",
+      extra: {
+        deviceID: Service.identity.hashedDeviceID(remoteID),
+        flowID,
+        numIDs: "2",
+      },
+    }], "Should record telemetry events for repair request");
+
+    // We should have started a repair with our second client.
+    equal((await clientsEngine.getClientCommands(remoteID)).length, 1,
+      "Should queue repair request for remote client after repair");
+    _("Sync to send outgoing repair request");
+    await Service.sync();
+    equal((await clientsEngine.getClientCommands(remoteID)).length, 0,
+      "Should send repair request to remote client after next sync");
+    checkRecordedEvents([],
+      "Should not record repair telemetry after sending repair request");
+
+    _("Back up repair state to restore later");
+    let restoreInitialRepairState = backupPrefs(BOOKMARK_REPAIR_STATE_PREFS);
+
+    // so now let's take over the role of that other client!
+    _("Create new clients engine pretending to be remote client");
+    let remoteClientsEngine = Service.clientsEngine = new ClientEngine(Service);
+    remoteClientsEngine.ignoreLastModifiedOnProcessCommands = true;
+    await remoteClientsEngine.initialize();
+    remoteClientsEngine.localID = remoteID;
+
+    _("Restore missing bookmark");
+    // Pretend Sync wrote the bookmark, so that we upload it as part of the
+    // repair instead of the sync.
+    bookmarkInfo.source = PlacesUtils.bookmarks.SOURCE_SYNC;
+    await PlacesUtils.bookmarks.insert(bookmarkInfo);
+    restoreRemoteLastBookmarkSync();
+
+    _("Sync as remote client");
+    await Service.sync();
+    checkRecordedEvents([{
+      object: "processcommand",
+      method: "repairRequest",
+      value: undefined,
+      extra: {
+        flowID,
+      },
+    }, {
+      object: "repairResponse",
+      method: "uploading",
+      value: undefined,
+      extra: {
+        flowID,
+        numIDs: "2",
+      },
+    }, {
+      object: "sendcommand",
+      method: "repairResponse",
+      value: undefined,
+      extra: {
+        flowID,
+        deviceID: Service.identity.hashedDeviceID(initialID),
+      },
+    }, {
+      object: "repairResponse",
+      method: "finished",
+      value: undefined,
+      extra: {
+        flowID,
+        numIDs: "2",
+      }
+    }], "Should record telemetry events for repair response");
+
+    // We should queue the repair response for the initial client.
+    equal((await remoteClientsEngine.getClientCommands(initialID)).length, 1,
+      "Should queue repair response for initial client after repair");
+    ok(user.collection("bookmarks").wbo(bookmarkInfo.guid),
+      "Should upload missing bookmark");
+
+    _("Sync to upload bookmark and send outgoing repair response");
+    await Service.sync();
+    equal((await remoteClientsEngine.getClientCommands(initialID)).length, 0,
+      "Should send repair response to initial client after next sync");
+    checkRecordedEvents([],
+      "Should not record repair telemetry after sending repair response");
+    ok(!Services.prefs.prefHasUserValue("services.sync.repairs.bookmarks.state"),
+      "Remote client should not be repairing");
+
+    _("Pretend to be initial client again");
+    Service.clientsEngine = clientsEngine;
+
+    _("Restore incomplete Places database and prefs");
+    await PlacesUtils.bookmarks.remove(bookmarkInfo.guid, {
+      source: PlacesUtils.bookmarks.SOURCE_SYNC,
+    });
+    restoreInitialRepairState();
+    ok(Services.prefs.prefHasUserValue("services.sync.repairs.bookmarks.state"),
+      "Initial client should still be repairing");
+
+    _("Sync as initial client");
+    let revalidationPromise = promiseValidationDone([]);
+    await Service.sync();
+    let restoredBookmarkInfo = await PlacesUtils.bookmarks.fetch(bookmarkInfo.guid);
+    ok(restoredBookmarkInfo, "Missing bookmark should be downloaded to initial client");
+    checkRecordedEvents([{
+      object: "processcommand",
+      method: "repairResponse",
+      value: undefined,
+      extra: {
+        flowID,
+      },
+    }, {
+      object: "repair",
+      method: "response",
+      value: "upload",
+      extra: {
+        flowID,
+        deviceID: Service.identity.hashedDeviceID(remoteID),
+        numIDs: "2",
+      },
+    }, {
+      object: "repair",
+      method: "finished",
+      value: undefined,
+      extra: {
+        flowID,
+        numIDs: "0",
+      },
+    }]);
+    await revalidationPromise;
+    ok(!Services.prefs.prefHasUserValue("services.sync.repairs.bookmarks.state"),
+      "Should clear repair pref after successfully completing repair");
+  } finally {
+    await cleanup(server);
+    clientsEngine = Service.clientsEngine = new ClientEngine(Service);
+    clientsEngine.ignoreLastModifiedOnProcessCommands = true;
+    clientsEngine.initialize();
+  }
+});
+
+add_task(async function test_repair_client_missing() {
+  forceBookmarkValidation();
+
+  _("Ensure that a record missing from the client only will get re-downloaded from the server");
+
+  let server = await serverForFoo(bookmarksEngine);
+  await SyncTestingInfrastructure(server);
+
+  let remoteID = Utils.makeGUID();
+  try {
+
+    _("Syncing to initialize crypto etc.");
+    await Service.sync();
+
+    _("Create remote client record");
+    server.insertWBO("foo", "clients", new ServerWBO(remoteID, encryptPayload({
+      id: remoteID,
+      name: "Remote client",
+      type: "desktop",
+      commands: [],
+      version: "54",
+      protocols: ["1.5"],
+    }), Date.now() / 1000));
+
+    let bookmarkInfo = await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+      url: "http://getfirefox.com/",
+      title: "Get Firefox!",
+    });
+
+    let validationPromise = promiseValidationDone([]);
+    _("Syncing.");
+    await Service.sync();
+    // should have 2 clients
+    equal(clientsEngine.stats.numClients, 2);
+    await validationPromise;
+
+    // Delete the bookmark localy, but cheat by telling places that Sync did
+    // it, so Sync still thinks we have it.
+    await PlacesUtils.bookmarks.remove(bookmarkInfo.guid, {
+      source: PlacesUtils.bookmarks.SOURCE_SYNC,
+    });
+    // sanity check we aren't going to sync this removal.
+    do_check_empty((await bookmarksEngine.pullNewChanges()));
+    // sanity check that the bookmark is not there anymore
+    do_check_false(await PlacesUtils.bookmarks.fetch(bookmarkInfo.guid));
+
+    // sync again - we should have a few problems...
+    _("Syncing again.");
+    validationPromise = promiseValidationDone([
+      {"name": "clientMissing", "count": 1},
+      {"name": "structuralDifferences", "count": 1},
+    ]);
+    await Service.sync();
+    await validationPromise;
+
+    // We shouldn't have started a repair with our second client.
+    equal((await clientsEngine.getClientCommands(remoteID)).length, 0);
+
+    // Trigger a sync (will request the missing item)
+    await Service.sync();
+
+    // And we got our bookmark back
+    do_check_true(await PlacesUtils.bookmarks.fetch(bookmarkInfo.guid));
+  } finally {
+    await cleanup(server);
+  }
+});
+
+add_task(async function test_repair_server_missing() {
+  forceBookmarkValidation();
+
+  _("Ensure that a record missing from the server only will get re-upload from the client");
+
+  let server = await serverForFoo(bookmarksEngine);
+  await SyncTestingInfrastructure(server);
+
+  let user = server.user("foo");
+
+  let remoteID = Utils.makeGUID();
+  try {
+
+    _("Syncing to initialize crypto etc.");
+    await Service.sync();
+
+    _("Create remote client record");
+    server.insertWBO("foo", "clients", new ServerWBO(remoteID, encryptPayload({
+      id: remoteID,
+      name: "Remote client",
+      type: "desktop",
+      commands: [],
+      version: "54",
+      protocols: ["1.5"],
+    }), Date.now() / 1000));
+
+    let bookmarkInfo = await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+      url: "http://getfirefox.com/",
+      title: "Get Firefox!",
+    });
+
+    let validationPromise = promiseValidationDone([]);
+    _("Syncing.");
+    await Service.sync();
+    // should have 2 clients
+    equal(clientsEngine.stats.numClients, 2);
+    await validationPromise;
+
+    // Now we will reach into the server and hard-delete the bookmark
+    user.collection("bookmarks").wbo(bookmarkInfo.guid).delete();
+
+    // sync again - we should have a few problems...
+    _("Syncing again.");
+    validationPromise = promiseValidationDone([
+      {"name": "serverMissing", "count": 1},
+      {"name": "missingChildren", "count": 1},
+    ]);
+    await Service.sync();
+    await validationPromise;
+
+    // We shouldn't have started a repair with our second client.
+    equal((await clientsEngine.getClientCommands(remoteID)).length, 0);
+
+    // Trigger a sync (will upload the missing item)
+    await Service.sync();
+
+    // And the server got our bookmark back
+    do_check_true(user.collection("bookmarks").wbo(bookmarkInfo.guid));
+  } finally {
+    await cleanup(server);
+  }
+});
+
+add_task(async function test_repair_server_deleted() {
+  forceBookmarkValidation();
+
+  _("Ensure that a record marked as deleted on the server but present on the client will get deleted on the client");
+
+  let server = await serverForFoo(bookmarksEngine);
+  await SyncTestingInfrastructure(server);
+
+  let remoteID = Utils.makeGUID();
+  try {
+
+    _("Syncing to initialize crypto etc.");
+    await Service.sync();
+
+    _("Create remote client record");
+    server.insertWBO("foo", "clients", new ServerWBO(remoteID, encryptPayload({
+      id: remoteID,
+      name: "Remote client",
+      type: "desktop",
+      commands: [],
+      version: "54",
+      protocols: ["1.5"],
+    }), Date.now() / 1000));
+
+    let bookmarkInfo = await PlacesUtils.bookmarks.insert({
+      parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+      url: "http://getfirefox.com/",
+      title: "Get Firefox!",
+    });
+
+    let validationPromise = promiseValidationDone([]);
+    _("Syncing.");
+    await Service.sync();
+    // should have 2 clients
+    equal(clientsEngine.stats.numClients, 2);
+    await validationPromise;
+
+    // Now we will reach into the server and create a tombstone for that bookmark
+    // but with a last-modified in the past - this way our sync isn't going to
+    // pick up the record.
+    server.insertWBO("foo", "bookmarks", new ServerWBO(bookmarkInfo.guid, encryptPayload({
+      id: bookmarkInfo.guid,
+      deleted: true,
+    }), (Date.now() - 60000) / 1000));
+
+    // sync again - we should have a few problems...
+    _("Syncing again.");
+    validationPromise = promiseValidationDone([
+      {"name": "serverDeleted", "count": 1},
+      {"name": "deletedChildren", "count": 1},
+      {"name": "orphans", "count": 1}
+    ]);
+    await Service.sync();
+    await validationPromise;
+
+    // We shouldn't have started a repair with our second client.
+    equal((await clientsEngine.getClientCommands(remoteID)).length, 0);
+
+    // Trigger a sync (will upload the missing item)
+    await Service.sync();
+
+    // And the client deleted our bookmark
+    do_check_true(!(await PlacesUtils.bookmarks.fetch(bookmarkInfo.guid)));
+  } finally {
+    await cleanup(server);
+  }
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/test/unit/test_bookmark_repair_requestor.js
@@ -0,0 +1,514 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+Cu.import("resource://sync-repair/bookmark_repair.jsm");
+
+initTestLogging("Trace");
+
+function makeClientRecord(id, fields = {}) {
+  return {
+    id,
+    version: fields.version || "54.0a1",
+    type: fields.type || "desktop",
+    stale: fields.stale || false,
+    serverLastModified: fields.serverLastModified || 0,
+  };
+}
+
+class MockClientsEngine {
+  constructor(clientList) {
+    this._clientList = clientList;
+    this._sentCommands = {};
+  }
+
+  get remoteClients() {
+    return Object.values(this._clientList);
+  }
+
+  remoteClient(id) {
+    return this._clientList[id];
+  }
+
+  async sendCommand(command, args, clientID) {
+    let cc = this._sentCommands[clientID] || [];
+    cc.push({ command, args });
+    this._sentCommands[clientID] = cc;
+  }
+
+  async getClientCommands(clientID) {
+    return this._sentCommands[clientID] || [];
+  }
+}
+
+class MockIdentity {
+  hashedDeviceID(did) {
+    return did; // don't hash it to make testing easier.
+  }
+}
+
+class MockService {
+  constructor(clientList) {
+    this.clientsEngine = new MockClientsEngine(clientList);
+    this.identity = new MockIdentity();
+    this._recordedEvents = [];
+  }
+
+  recordTelemetryEvent(object, method, value, extra = undefined) {
+    this._recordedEvents.push({ method, object, value, extra });
+  }
+}
+
+function checkState(expected) {
+  equal(Services.prefs.getCharPref("services.sync.repairs.bookmarks.state"), expected);
+}
+
+function checkRepairFinished() {
+  try {
+    let state = Services.prefs.getCharPref("services.sync.repairs.bookmarks.state");
+    ok(false, state);
+  } catch (ex) {
+    ok(true, "no repair preference exists");
+  }
+}
+
+function checkOutgoingCommand(service, clientID) {
+  let sent = service.clientsEngine._sentCommands;
+  deepEqual(Object.keys(sent), [clientID]);
+  equal(sent[clientID].length, 1);
+  equal(sent[clientID][0].command, "repairRequest");
+}
+
+function NewBookmarkRepairRequestor(mockService) {
+  let req = new BookmarkRepairRequestor(mockService);
+  req._now = () => Date.now() / 1000; // _now() is seconds.
+  return req;
+}
+
+add_task(async function test_requestor_no_clients() {
+  let mockService = new MockService({ });
+  let requestor = NewBookmarkRepairRequestor(mockService);
+  let validationInfo = {
+    problems: {
+      missingChildren: [
+        {parent: "x", child: "a"},
+        {parent: "x", child: "b"},
+        {parent: "x", child: "c"}
+      ],
+      orphans: [],
+    }
+  };
+  let flowID = Utils.makeGUID();
+
+  await requestor.startRepairs(validationInfo, flowID);
+  // there are no clients, so we should end up in "finished" (which we need to
+  // check via telemetry)
+  deepEqual(mockService._recordedEvents, [
+    { object: "repair",
+      method: "started",
+      value: undefined,
+      extra: { flowID, numIDs: 4 },
+    },
+    { object: "repair",
+      method: "finished",
+      value: undefined,
+      extra: { flowID, numIDs: 4 },
+    }
+  ]);
+});
+
+add_task(async function test_requestor_one_client_no_response() {
+  let mockService = new MockService({ "client-a": makeClientRecord("client-a") });
+  let requestor = NewBookmarkRepairRequestor(mockService);
+  let validationInfo = {
+    problems: {
+      missingChildren: [
+        {parent: "x", child: "a"},
+        {parent: "x", child: "b"},
+        {parent: "x", child: "c"}
+      ],
+      orphans: [],
+    }
+  };
+  let flowID = Utils.makeGUID();
+  await requestor.startRepairs(validationInfo, flowID);
+  // the command should now be outgoing.
+  checkOutgoingCommand(mockService, "client-a");
+
+  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
+  // asking it to continue stays in that state until we timeout or the command
+  // is removed.
+  await requestor.continueRepairs();
+  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
+
+  // now pretend that client synced.
+  mockService.clientsEngine._sentCommands = {};
+  await requestor.continueRepairs();
+  checkState(BookmarkRepairRequestor.STATE.SENT_SECOND_REQUEST);
+  // the command should be outgoing again.
+  checkOutgoingCommand(mockService, "client-a");
+
+  // pretend that client synced again without writing a command.
+  mockService.clientsEngine._sentCommands = {};
+  await requestor.continueRepairs();
+  // There are no more clients, so we've given up.
+
+  checkRepairFinished();
+  deepEqual(mockService._recordedEvents, [
+    { object: "repair",
+      method: "started",
+      value: undefined,
+      extra: { flowID, numIDs: 4 },
+    },
+    { object: "repair",
+      method: "request",
+      value: "upload",
+      extra: { flowID, numIDs: 4, deviceID: "client-a" },
+    },
+    { object: "repair",
+      method: "request",
+      value: "upload",
+      extra: { flowID, numIDs: 4, deviceID: "client-a" },
+    },
+    { object: "repair",
+      method: "finished",
+      value: undefined,
+      extra: { flowID, numIDs: 4 },
+    }
+  ]);
+});
+
+add_task(async function test_requestor_one_client_no_sync() {
+  let mockService = new MockService({ "client-a": makeClientRecord("client-a") });
+  let requestor = NewBookmarkRepairRequestor(mockService);
+  let validationInfo = {
+    problems: {
+      missingChildren: [
+        {parent: "x", child: "a"},
+        {parent: "x", child: "b"},
+        {parent: "x", child: "c"}
+      ],
+      orphans: [],
+    }
+  };
+  let flowID = Utils.makeGUID();
+  await requestor.startRepairs(validationInfo, flowID);
+  // the command should now be outgoing.
+  checkOutgoingCommand(mockService, "client-a");
+
+  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
+
+  // pretend we are now in the future.
+  let theFuture = Date.now() + 300000000;
+  requestor._now = () => theFuture;
+
+  await requestor.continueRepairs();
+
+  // We should be finished as we gave up in disgust.
+  checkRepairFinished();
+  deepEqual(mockService._recordedEvents, [
+    { object: "repair",
+      method: "started",
+      value: undefined,
+      extra: { flowID, numIDs: 4 },
+    },
+    { object: "repair",
+      method: "request",
+      value: "upload",
+      extra: { flowID, numIDs: 4, deviceID: "client-a" },
+    },
+    { object: "repair",
+      method: "abandon",
+      value: "silent",
+      extra: { flowID, deviceID: "client-a" },
+    },
+    { object: "repair",
+      method: "finished",
+      value: undefined,
+      extra: { flowID, numIDs: 4 },
+    }
+  ]);
+});
+
+add_task(async function test_requestor_latest_client_used() {
+  let mockService = new MockService({
+    "client-early": makeClientRecord("client-early", { serverLastModified: Date.now() - 10 }),
+    "client-late": makeClientRecord("client-late", { serverLastModified: Date.now() }),
+  });
+  let requestor = NewBookmarkRepairRequestor(mockService);
+  let validationInfo = {
+    problems: {
+      missingChildren: [
+        { parent: "x", child: "a" },
+      ],
+      orphans: [],
+    }
+  };
+  await requestor.startRepairs(validationInfo, Utils.makeGUID());
+  // the repair command should be outgoing to the most-recent client.
+  checkOutgoingCommand(mockService, "client-late");
+  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
+  // and this test is done - reset the repair.
+  requestor.prefs.resetBranch();
+});
+
+add_task(async function test_requestor_client_vanishes() {
+  let mockService = new MockService({
+    "client-a": makeClientRecord("client-a"),
+    "client-b": makeClientRecord("client-b"),
+  });
+  let requestor = NewBookmarkRepairRequestor(mockService);
+  let validationInfo = {
+    problems: {
+      missingChildren: [
+        {parent: "x", child: "a"},
+        {parent: "x", child: "b"},
+        {parent: "x", child: "c"}
+      ],
+      orphans: [],
+    }
+  };
+  let flowID = Utils.makeGUID();
+  await requestor.startRepairs(validationInfo, flowID);
+  // the command should now be outgoing.
+  checkOutgoingCommand(mockService, "client-a");
+
+  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
+
+  mockService.clientsEngine._sentCommands = {};
+  // Now let's pretend the client vanished.
+  delete mockService.clientsEngine._clientList["client-a"];
+
+  await requestor.continueRepairs();
+  // We should have moved on to client-b.
+  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
+  checkOutgoingCommand(mockService, "client-b");
+
+  // Now let's pretend client B wrote all missing IDs.
+  let response = {
+    collection: "bookmarks",
+    request: "upload",
+    flowID: requestor._flowID,
+    clientID: "client-b",
+    ids: ["a", "b", "c", "x"],
+  };
+  await requestor.continueRepairs(response);
+
+  // We should be finished as we got all our IDs.
+  checkRepairFinished();
+  deepEqual(mockService._recordedEvents, [
+    { object: "repair",
+      method: "started",
+      value: undefined,
+      extra: { flowID, numIDs: 4 },
+    },
+    { object: "repair",
+      method: "request",
+      value: "upload",
+      extra: { flowID, numIDs: 4, deviceID: "client-a" },
+    },
+    { object: "repair",
+      method: "abandon",
+      value: "missing",
+      extra: { flowID, deviceID: "client-a" },
+    },
+    { object: "repair",
+      method: "request",
+      value: "upload",
+      extra: { flowID, numIDs: 4, deviceID: "client-b" },
+    },
+    { object: "repair",
+      method: "response",
+      value: "upload",
+      extra: { flowID, deviceID: "client-b", numIDs: 4 },
+    },
+    { object: "repair",
+      method: "finished",
+      value: undefined,
+      extra: { flowID, numIDs: 0 },
+    }
+  ]);
+});
+
+add_task(async function test_requestor_success_responses() {
+  let mockService = new MockService({
+    "client-a": makeClientRecord("client-a"),
+    "client-b": makeClientRecord("client-b"),
+  });
+  let requestor = NewBookmarkRepairRequestor(mockService);
+  let validationInfo = {
+    problems: {
+      missingChildren: [
+        {parent: "x", child: "a"},
+        {parent: "x", child: "b"},
+        {parent: "x", child: "c"}
+      ],
+      orphans: [],
+    }
+  };
+  let flowID = Utils.makeGUID();
+  await requestor.startRepairs(validationInfo, flowID);
+  // the command should now be outgoing.
+  checkOutgoingCommand(mockService, "client-a");
+
+  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
+
+  mockService.clientsEngine._sentCommands = {};
+  // Now let's pretend the client wrote a response.
+  let response = {
+    collection: "bookmarks",
+    request: "upload",
+    clientID: "client-a",
+    flowID: requestor._flowID,
+    ids: ["a", "b"],
+  };
+  await requestor.continueRepairs(response);
+  // We should have moved on to client 2.
+  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
+  checkOutgoingCommand(mockService, "client-b");
+
+  // Now let's pretend client B write the missing ID.
+  response = {
+    collection: "bookmarks",
+    request: "upload",
+    clientID: "client-b",
+    flowID: requestor._flowID,
+    ids: ["c", "x"],
+  };
+  await requestor.continueRepairs(response);
+
+  // We should be finished as we got all our IDs.
+  checkRepairFinished();
+  deepEqual(mockService._recordedEvents, [
+    { object: "repair",
+      method: "started",
+      value: undefined,
+      extra: { flowID, numIDs: 4 },
+    },
+    { object: "repair",
+      method: "request",
+      value: "upload",
+      extra: { flowID, numIDs: 4, deviceID: "client-a" },
+    },
+    { object: "repair",
+      method: "response",
+      value: "upload",
+      extra: { flowID, deviceID: "client-a", numIDs: 2 },
+    },
+    { object: "repair",
+      method: "request",
+      value: "upload",
+      extra: { flowID, numIDs: 2, deviceID: "client-b" },
+    },
+    { object: "repair",
+      method: "response",
+      value: "upload",
+      extra: { flowID, deviceID: "client-b", numIDs: 2 },
+    },
+    { object: "repair",
+      method: "finished",
+      value: undefined,
+      extra: { flowID, numIDs: 0 },
+    }
+  ]);
+});
+
+add_task(async function test_client_suitability() {
+  let mockService = new MockService({
+    "client-a": makeClientRecord("client-a"),
+    "client-b": makeClientRecord("client-b", { type: "mobile" }),
+    "client-c": makeClientRecord("client-c", { version: "52.0a1" }),
+    "client-d": makeClientRecord("client-c", { version: "54.0a1" }),
+  });
+  let requestor = NewBookmarkRepairRequestor(mockService);
+  ok(requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-a")));
+  ok(!requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-b")));
+  ok(!requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-c")));
+  ok(requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-d")));
+});
+
+add_task(async function test_requestor_already_repairing_at_start() {
+  let mockService = new MockService({ });
+  let requestor = NewBookmarkRepairRequestor(mockService);
+  requestor.anyClientsRepairing = () => true;
+  let validationInfo = {
+    problems: {
+      missingChildren: [
+        {parent: "x", child: "a"},
+        {parent: "x", child: "b"},
+        {parent: "x", child: "c"}
+      ],
+      orphans: [],
+    }
+  };
+  let flowID = Utils.makeGUID();
+
+  ok(!(await requestor.startRepairs(validationInfo, flowID)),
+     "Shouldn't start repairs");
+  equal(mockService._recordedEvents.length, 1);
+  equal(mockService._recordedEvents[0].method, "aborted");
+});
+
+add_task(async function test_requestor_already_repairing_continue() {
+  let clientB = makeClientRecord("client-b");
+  let mockService = new MockService({
+    "client-a": makeClientRecord("client-a"),
+    "client-b": clientB
+  });
+  let requestor = NewBookmarkRepairRequestor(mockService);
+  let validationInfo = {
+    problems: {
+      missingChildren: [
+        {parent: "x", child: "a"},
+        {parent: "x", child: "b"},
+        {parent: "x", child: "c"}
+      ],
+      orphans: [],
+    }
+  };
+  let flowID = Utils.makeGUID();
+  await requestor.startRepairs(validationInfo, flowID);
+  // the command should now be outgoing.
+  checkOutgoingCommand(mockService, "client-a");
+
+  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
+  mockService.clientsEngine._sentCommands = {};
+
+  // Now let's pretend the client wrote a response (it doesn't matter what's in here)
+  let response = {
+    collection: "bookmarks",
+    request: "upload",
+    clientID: "client-a",
+    flowID: requestor._flowID,
+    ids: ["a", "b"],
+  };
+
+  // and another client also started a request
+  clientB.commands = [{
+    args: [{ collection: "bookmarks", flowID: "asdf" }],
+    command: "repairRequest",
+  }];
+
+
+  await requestor.continueRepairs(response);
+
+  // We should have aborted now
+  checkRepairFinished();
+  const expected = [
+    { method: "started",
+      object: "repair",
+      value: undefined,
+      extra: { flowID, numIDs: "4" },
+    },
+    { method: "request",
+      object: "repair",
+      value: "upload",
+      extra: { flowID, numIDs: "4", deviceID: "client-a" },
+    },
+    { method: "aborted",
+      object: "repair",
+      value: undefined,
+      extra: { flowID, numIDs: "4", reason: "other clients repairing" },
+    }
+  ];
+
+  deepEqual(mockService._recordedEvents, expected);
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/test/unit/test_bookmark_repair_responder.js
@@ -0,0 +1,611 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.import("resource:///modules/PlacesUIUtils.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://services-sync/constants.js");
+Cu.import("resource://services-sync/engines/bookmarks.js");
+Cu.import("resource://services-sync/service.js");
+Cu.import("resource://sync-repair/bookmark_repair.jsm");
+Cu.import("resource://testing-common/services/sync/utils.js");
+
+initTestLogging("Trace");
+Log.repository.getLogger("Sync.Engine.Bookmarks").level = Log.Level.Trace;
+
+// Disable validation so that we don't try to automatically repair the server
+// when we sync.
+Svc.Prefs.set("engine.bookmarks.validation.enabled", false);
+
+// stub telemetry so we can easily check the right things are recorded.
+var recordedEvents = [];
+
+function checkRecordedEvents(expected) {
+  deepEqual(recordedEvents, expected);
+  // and clear the list so future checks are easier to write.
+  recordedEvents = [];
+}
+
+function getServerBookmarks(server) {
+  return server.user("foo").collection("bookmarks");
+}
+
+async function makeServer() {
+  let server = await serverForFoo(bookmarksEngine);
+  await SyncTestingInfrastructure(server);
+  return server;
+}
+
+async function cleanup(server) {
+  await promiseStopServer(server);
+  await PlacesSyncUtils.bookmarks.wipe();
+  // clear keys so when each test finds a different server it accepts its keys.
+  Service.collectionKeys.clear();
+}
+
+let bookmarksEngine;
+
+add_task(async function setup() {
+  bookmarksEngine = Service.engineManager.get("bookmarks");
+
+  Service.recordTelemetryEvent = (object, method, value, extra = undefined) => {
+    recordedEvents.push({ object, method, value, extra });
+  };
+});
+
+add_task(async function test_responder_error() {
+  let server = await makeServer();
+
+  // sync so the collection is created.
+  await Service.sync();
+
+  let request = {
+    request: "upload",
+    ids: [Utils.makeGUID()],
+    flowID: Utils.makeGUID(),
+  };
+  let responder = new BookmarkRepairResponder();
+  // mock the responder to simulate an error.
+  responder._fetchItemsToUpload = async function() {
+    throw new Error("oh no!");
+  };
+  await responder.repair(request, null);
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "failed",
+      value: undefined,
+      extra: { flowID: request.flowID,
+               numIDs: "0",
+               failureReason: '{"name":"unexpectederror","error":"Error: oh no!"}',
+      }
+    },
+  ]);
+
+  await cleanup(server);
+});
+
+add_task(async function test_responder_no_items() {
+  let server = await makeServer();
+
+  // sync so the collection is created.
+  await Service.sync();
+
+  let request = {
+    request: "upload",
+    ids: [Utils.makeGUID()],
+    flowID: Utils.makeGUID(),
+  };
+  let responder = new BookmarkRepairResponder();
+  await responder.repair(request, null);
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "finished",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "0"},
+    },
+  ]);
+
+  await cleanup(server);
+});
+
+// One item requested and we have it locally, but it's not yet on the server.
+add_task(async function test_responder_upload() {
+  let server = await makeServer();
+
+  // Pretend we've already synced this bookmark, so that we can ensure it's
+  // uploaded in response to our repair request.
+  let bm = await PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+                                                title: "Get Firefox",
+                                                url: "http://getfirefox.com/",
+                                                source: PlacesUtils.bookmarks.SOURCES.SYNC });
+
+  await Service.sync();
+  deepEqual(getServerBookmarks(server).keys().sort(), [
+    "menu",
+    "mobile",
+    "toolbar",
+    "unfiled",
+  ], "Should only upload roots on first sync");
+
+  let request = {
+    request: "upload",
+    ids: [bm.guid],
+    flowID: Utils.makeGUID(),
+  };
+  let responder = new BookmarkRepairResponder();
+  await responder.repair(request, null);
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "uploading",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "1"},
+    },
+  ]);
+
+  await Service.sync();
+  deepEqual(getServerBookmarks(server).keys().sort(), [
+    "menu",
+    "mobile",
+    "toolbar",
+    "unfiled",
+    bm.guid,
+  ].sort(), "Should upload requested bookmark on second sync");
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "finished",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "1"},
+    },
+  ]);
+
+  await cleanup(server);
+});
+
+// One item requested and we have it locally and it's already on the server.
+// As it was explicitly requested, we should upload it.
+add_task(async function test_responder_item_exists_locally() {
+  let server = await makeServer();
+
+  let bm = await PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+                                                title: "Get Firefox",
+                                                url: "http://getfirefox.com/" });
+  // first sync to get the item on the server.
+  _("Syncing to get item on the server");
+  await Service.sync();
+
+  // issue a repair request for it.
+  let request = {
+    request: "upload",
+    ids: [bm.guid],
+    flowID: Utils.makeGUID(),
+  };
+  let responder = new BookmarkRepairResponder();
+  await responder.repair(request, null);
+
+  // We still re-upload the item.
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "uploading",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "1"},
+    },
+  ]);
+
+  _("Syncing to do the upload.");
+  await Service.sync();
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "finished",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "1"},
+    },
+  ]);
+  await cleanup(server);
+});
+
+add_task(async function test_responder_tombstone() {
+  let server = await makeServer();
+
+  // TODO: Request an item for which we have a tombstone locally. Decide if
+  // we want to store tombstones permanently for this. In the integration
+  // test, we can also try requesting a deleted child or ancestor.
+
+  // For now, we'll handle this identically to `test_responder_missing_items`.
+  // Bug 1343103 is a follow-up to better handle this.
+  await cleanup(server);
+});
+
+add_task(async function test_responder_missing_items() {
+  let server = await makeServer();
+
+  let fxBmk = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+    title: "Get Firefox",
+    url: "http://getfirefox.com/",
+  });
+  let tbBmk = await PlacesUtils.bookmarks.insert({
+    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+    title: "Get Thunderbird",
+    url: "http://getthunderbird.com/",
+    // Pretend we've already synced Thunderbird.
+    source: PlacesUtils.bookmarks.SOURCES.SYNC,
+  });
+
+  await Service.sync();
+  deepEqual(getServerBookmarks(server).keys().sort(), [
+    "menu",
+    "mobile",
+    "toolbar",
+    "unfiled",
+    fxBmk.guid,
+  ].sort(), "Should upload roots and Firefox on first sync");
+
+  _("Request Firefox, Thunderbird, and nonexistent GUID");
+  let request = {
+    request: "upload",
+    ids: [fxBmk.guid, tbBmk.guid, Utils.makeGUID()],
+    flowID: Utils.makeGUID(),
+  };
+  let responder = new BookmarkRepairResponder();
+  await responder.repair(request, null);
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "uploading",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "2"},
+    },
+  ]);
+
+  _("Sync after requesting IDs");
+  await Service.sync();
+  deepEqual(getServerBookmarks(server).keys().sort(), [
+    "menu",
+    "mobile",
+    "toolbar",
+    "unfiled",
+    fxBmk.guid,
+    tbBmk.guid,
+  ].sort(), "Second sync should upload Thunderbird; skip nonexistent");
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "finished",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "2"},
+    },
+  ]);
+
+  await cleanup(server);
+});
+
+add_task(async function test_non_syncable() {
+  let server = await makeServer();
+
+  await Service.sync(); // to create the collections on the server.
+
+  // Creates the left pane queries as a side effect.
+  let leftPaneId = PlacesUIUtils.leftPaneFolderId;
+  _(`Left pane root ID: ${leftPaneId}`);
+  await PlacesTestUtils.promiseAsyncUpdates();
+
+  // A child folder of the left pane root, containing queries for the menu,
+  // toolbar, and unfiled queries.
+  let allBookmarksId = PlacesUIUtils.leftPaneQueries.AllBookmarks;
+  let allBookmarksGuid = await PlacesUtils.promiseItemGuid(allBookmarksId);
+
+  let unfiledQueryId = PlacesUIUtils.leftPaneQueries.UnfiledBookmarks;
+  let unfiledQueryGuid = await PlacesUtils.promiseItemGuid(unfiledQueryId);
+
+  // Put the "Bookmarks Menu" on the server to simulate old bugs.
+  let bookmarksMenuQueryId = PlacesUIUtils.leftPaneQueries.BookmarksMenu;
+  let bookmarksMenuQueryGuid = await PlacesUtils.promiseItemGuid(bookmarksMenuQueryId);
+  let collection = getServerBookmarks(server);
+  collection.insert(bookmarksMenuQueryGuid, "doesn't matter");
+
+  // Explicitly request the unfiled and allBookmarksGuid queries; these will
+  // get tombstones. Because the BookmarksMenu is already on the server it
+  // should be removed even though it wasn't requested. We should ignore the
+  // toolbar query as it wasn't explicitly requested and isn't on the server.
+  let request = {
+    request: "upload",
+    ids: [allBookmarksGuid, unfiledQueryGuid],
+    flowID: Utils.makeGUID(),
+  };
+  let responder = new BookmarkRepairResponder();
+  await responder.repair(request, null);
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "uploading",
+      value: undefined,
+      // Tombstones for the 2 items we requested and for bookmarksMenu
+      extra: {flowID: request.flowID, numIDs: "3"},
+    },
+  ]);
+
+  _("Sync to upload tombstones for items");
+  await Service.sync();
+
+  let toolbarQueryId = PlacesUIUtils.leftPaneQueries.BookmarksToolbar;
+  let menuQueryId = PlacesUIUtils.leftPaneQueries.BookmarksMenu;
+  let queryGuids = [
+    allBookmarksGuid,
+    await PlacesUtils.promiseItemGuid(toolbarQueryId),
+    await PlacesUtils.promiseItemGuid(menuQueryId),
+    unfiledQueryGuid,
+  ];
+
+  deepEqual(collection.keys().sort(), [
+    // We always upload roots on the first sync.
+    "menu",
+    "mobile",
+    "toolbar",
+    "unfiled",
+    ...request.ids,
+    bookmarksMenuQueryGuid,
+  ].sort(), "Should upload roots and queries on first sync");
+
+  for (let guid of queryGuids) {
+    let wbo = collection.wbo(guid);
+    if (request.ids.indexOf(guid) >= 0 || guid == bookmarksMenuQueryGuid) {
+      // explicitly requested or already on the server, so should have a tombstone.
+      let payload = JSON.parse(JSON.parse(wbo.payload).ciphertext);
+      ok(payload.deleted, `Should upload tombstone for left pane query ${guid}`);
+    } else {
+      // not explicitly requested and not on the server at the start, so should
+      // not be on the server now.
+      ok(!wbo, `Should not upload anything for left pane query ${guid}`);
+    }
+  }
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "finished",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "3"},
+    },
+  ]);
+
+  await cleanup(server);
+});
+
+add_task(async function test_folder_descendants() {
+  let server = await makeServer();
+
+  let parentFolder = await PlacesUtils.bookmarks.insert({
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    parentGuid: PlacesUtils.bookmarks.menuGuid,
+    title: "Parent folder",
+  });
+  let childFolder = await PlacesUtils.bookmarks.insert({
+    type: PlacesUtils.bookmarks.TYPE_FOLDER,
+    parentGuid: parentFolder.guid,
+    title: "Child folder",
+  });
+  // This item is in parentFolder and *should not* be uploaded as part of
+  // the repair even though we explicitly request its parent.
+  let existingChildBmk = await PlacesUtils.bookmarks.insert({
+    parentGuid: parentFolder.guid,
+    title: "Get Firefox",
+    url: "http://firefox.com",
+  });
+  // This item is in parentFolder and *should* be uploaded as part of
+  // the repair because we explicitly request its ID.
+  let childSiblingBmk = await PlacesUtils.bookmarks.insert({
+    parentGuid: parentFolder.guid,
+    title: "Get Thunderbird",
+    url: "http://getthunderbird.com",
+  });
+
+  _("Initial sync to upload roots and parent folder");
+  await Service.sync();
+
+  let initialRecordIds = [
+    "menu",
+    "mobile",
+    "toolbar",
+    "unfiled",
+    parentFolder.guid,
+    existingChildBmk.guid,
+    childFolder.guid,
+    childSiblingBmk.guid,
+  ].sort();
+  deepEqual(getServerBookmarks(server).keys().sort(), initialRecordIds,
+    "Should upload roots and partial folder contents on first sync");
+
+  _("Insert missing bookmarks locally to request later");
+  // Note that the fact we insert the bookmarks via PlacesSyncUtils.bookmarks.insert
+  // means that we are pretending Sync itself wrote them, hence they aren't
+  // considered "changed" locally so never get uploaded.
+  let childBmk = await PlacesSyncUtils.bookmarks.insert({
+    kind: "bookmark",
+    recordId: Utils.makeGUID(),
+    parentRecordId: parentFolder.guid,
+    title: "Get Firefox",
+    url: "http://getfirefox.com",
+  });
+  let grandChildBmk = await PlacesSyncUtils.bookmarks.insert({
+    kind: "bookmark",
+    recordId: Utils.makeGUID(),
+    parentRecordId: childFolder.guid,
+    title: "Bugzilla",
+    url: "https://bugzilla.mozilla.org",
+  });
+  let grandChildSiblingBmk = await PlacesSyncUtils.bookmarks.insert({
+    kind: "bookmark",
+    recordId: Utils.makeGUID(),
+    parentRecordId: childFolder.guid,
+    title: "Mozilla",
+    url: "https://mozilla.org",
+  });
+
+  _("Sync again; server contents shouldn't change");
+  await Service.sync();
+  deepEqual(getServerBookmarks(server).keys().sort(), initialRecordIds,
+    "Second sync should not upload missing bookmarks");
+
+  // This assumes the parent record on the server is correct, and the server
+  // is just missing the children. This isn't a correct assumption if the
+  // parent's `children` array is wrong, or if the parent and children disagree.
+  _("Request missing bookmarks");
+  let request = {
+    request: "upload",
+    ids: [
+      // Already on server (but still uploaded as they are explicitly requested)
+      parentFolder.guid,
+      childSiblingBmk.guid,
+      // Explicitly upload these. We should also upload `grandChildBmk`,
+      // since it's a descendant of `parentFolder` and we requested its
+      // ancestor.
+      childBmk.recordId,
+      grandChildSiblingBmk.recordId],
+    flowID: Utils.makeGUID(),
+  };
+  let responder = new BookmarkRepairResponder();
+  await responder.repair(request, null);
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "uploading",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "5"},
+    },
+  ]);
+
+  _("Sync after requesting repair; should upload missing records");
+  await Service.sync();
+  deepEqual(getServerBookmarks(server).keys().sort(), [
+    ...initialRecordIds,
+    childBmk.recordId,
+    grandChildBmk.recordId,
+    grandChildSiblingBmk.recordId,
+  ].sort(), "Third sync should upload requested items");
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "finished",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "5"},
+    },
+  ]);
+
+  await cleanup(server);
+});
+
+// Error handling.
+add_task(async function test_aborts_unknown_request() {
+  let server = await makeServer();
+
+  let request = {
+    request: "not-upload",
+    ids: [],
+    flowID: Utils.makeGUID(),
+  };
+  let responder = new BookmarkRepairResponder();
+  await responder.repair(request, null);
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "aborted",
+      value: undefined,
+      extra: { flowID: request.flowID,
+               reason: "Don't understand request type 'not-upload'",
+             },
+    },
+  ]);
+  await cleanup(server);
+});
+
+add_task(async function test_upload_fail() {
+  let server = await makeServer();
+
+  // Pretend we've already synced this bookmark, so that we can ensure it's
+  // uploaded in response to our repair request.
+  let bm = await PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+                                                title: "Get Firefox",
+                                                url: "http://getfirefox.com/",
+                                                source: PlacesUtils.bookmarks.SOURCES.SYNC });
+
+  await Service.sync();
+  let request = {
+    request: "upload",
+    ids: [bm.guid],
+    flowID: Utils.makeGUID(),
+  };
+  let responder = new BookmarkRepairResponder();
+  await responder.repair(request, null);
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "uploading",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "1"},
+    },
+  ]);
+
+  // This sync would normally upload the item - arrange for it to fail.
+  let engine = Service.engineManager.get("bookmarks");
+  let oldCreateRecord = engine._createRecord;
+  engine._createRecord = async function(id) {
+    return "anything"; // doesn't have an "encrypt"
+  };
+
+  let numFailures = 0;
+  let numSuccesses = 0;
+  function onUploaded(subject, data) {
+    if (data != "bookmarks") {
+      return;
+    }
+    if (subject.failed) {
+      numFailures += 1;
+    } else {
+      numSuccesses += 1;
+    }
+  }
+  Svc.Obs.add("weave:engine:sync:uploaded", onUploaded, this);
+
+  await Service.sync();
+
+  equal(numFailures, 1);
+  equal(numSuccesses, 0);
+
+  // should be no recorded events
+  checkRecordedEvents([]);
+
+  // restore the error injection so next sync succeeds - the repair should
+  // restart
+  engine._createRecord = oldCreateRecord;
+  await responder.repair(request, null);
+
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "uploading",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "1"},
+    },
+  ]);
+
+  await Service.sync();
+  checkRecordedEvents([
+    { object: "repairResponse",
+      method: "finished",
+      value: undefined,
+      extra: {flowID: request.flowID, numIDs: "1"},
+    },
+  ]);
+
+  equal(numFailures, 1);
+  equal(numSuccesses, 1);
+
+  Svc.Obs.remove("weave:engine:sync:uploaded", onUploaded, this);
+  await cleanup(server);
+});
+
+add_task(async function teardown() {
+  Svc.Prefs.reset("engine.bookmarks.validation.enabled");
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/test/unit/test_bookmark_validator.js
@@ -0,0 +1,404 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Components.utils.import("resource://sync-repair/bookmark_validator.jsm");
+Components.utils.import("resource://services-sync/util.js");
+
+function run_test() {
+  do_get_profile();
+  run_next_test();
+}
+
+async function inspectServerRecords(data) {
+  let validator = new BookmarkValidator();
+  return validator.inspectServerRecords(data);
+}
+
+async function compareServerWithClient(server, client) {
+  let validator = new BookmarkValidator();
+  return validator.compareServerWithClient(server, client);
+}
+
+add_task(async function test_isr_rootOnServer() {
+  let c = await inspectServerRecords([{
+    id: "places",
+    type: "folder",
+    children: [],
+  }]);
+  ok(c.problemData.rootOnServer);
+});
+
+add_task(async function test_isr_empty() {
+  let c = await inspectServerRecords([]);
+  ok(!c.problemData.rootOnServer);
+  notEqual(c.root, null);
+});
+
+add_task(async function test_isr_cycles() {
+  let c = (await inspectServerRecords([
+    {id: "C", type: "folder", children: ["A", "B"], parentid: "places"},
+    {id: "A", type: "folder", children: ["B"], parentid: "B"},
+    {id: "B", type: "folder", children: ["A"], parentid: "A"},
+  ])).problemData;
+
+  equal(c.cycles.length, 1);
+  ok(c.cycles[0].indexOf("A") >= 0);
+  ok(c.cycles[0].indexOf("B") >= 0);
+});
+
+add_task(async function test_isr_orphansMultiParents() {
+  let c = (await inspectServerRecords([
+    { id: "A", type: "bookmark", parentid: "D" },
+    { id: "B", type: "folder", parentid: "places", children: ["A"]},
+    { id: "C", type: "folder", parentid: "places", children: ["A"]},
+
+  ])).problemData;
+  deepEqual(c.orphans, [{ id: "A", parent: "D" }]);
+  equal(c.multipleParents.length, 1);
+  ok(c.multipleParents[0].parents.indexOf("B") >= 0);
+  ok(c.multipleParents[0].parents.indexOf("C") >= 0);
+});
+
+add_task(async function test_isr_orphansMultiParents2() {
+  let c = (await inspectServerRecords([
+    { id: "A", type: "bookmark", parentid: "D" },
+    { id: "B", type: "folder", parentid: "places", children: ["A"]},
+  ])).problemData;
+  equal(c.orphans.length, 1);
+  equal(c.orphans[0].id, "A");
+  equal(c.multipleParents.length, 0);
+});
+
+add_task(async function test_isr_deletedParents() {
+  let c = (await inspectServerRecords([
+    { id: "A", type: "bookmark", parentid: "B" },
+    { id: "B", type: "folder", parentid: "places", children: ["A"]},
+    { id: "B", type: "item", deleted: true},
+  ])).problemData;
+  deepEqual(c.deletedParents, ["A"]);
+});
+
+add_task(async function test_isr_badChildren() {
+  let c = (await inspectServerRecords([
+    { id: "A", type: "bookmark", parentid: "places", children: ["B", "C"] },
+    { id: "C", type: "bookmark", parentid: "A" }
+  ])).problemData;
+  deepEqual(c.childrenOnNonFolder, ["A"]);
+  deepEqual(c.missingChildren, [{parent: "A", child: "B"}]);
+  deepEqual(c.parentNotFolder, ["C"]);
+});
+
+
+add_task(async function test_isr_parentChildMismatches() {
+  let c = (await inspectServerRecords([
+    { id: "A", type: "folder", parentid: "places", children: [] },
+    { id: "B", type: "bookmark", parentid: "A" }
+  ])).problemData;
+  deepEqual(c.parentChildMismatches, [{parent: "A", child: "B"}]);
+});
+
+add_task(async function test_isr_duplicatesAndMissingIDs() {
+  let c = (await inspectServerRecords([
+    {id: "A", type: "folder", parentid: "places", children: []},
+    {id: "A", type: "folder", parentid: "places", children: []},
+    {type: "folder", parentid: "places", children: []}
+  ])).problemData;
+  equal(c.missingIDs, 1);
+  deepEqual(c.duplicates, ["A"]);
+});
+
+add_task(async function test_isr_duplicateChildren() {
+  let c = (await inspectServerRecords([
+    {id: "A", type: "folder", parentid: "places", children: ["B", "B"]},
+    {id: "B", type: "bookmark", parentid: "A"},
+  ])).problemData;
+  deepEqual(c.duplicateChildren, ["A"]);
+});
+
+// Each compareServerWithClient test mutates these, so we can"t just keep them
+// global
+function getDummyServerAndClient() {
+  let server = [
+    {
+      id: "menu",
+      parentid: "places",
+      type: "folder",
+      parentName: "",
+      title: "foo",
+      children: ["bbbbbbbbbbbb", "cccccccccccc"]
+    },
+    {
+      id: "bbbbbbbbbbbb",
+      type: "bookmark",
+      parentid: "menu",
+      parentName: "foo",
+      title: "bar",
+      bmkUri: "http://baz.com"
+    },
+    {
+      id: "cccccccccccc",
+      parentid: "menu",
+      parentName: "foo",
+      title: "",
+      type: "query",
+      bmkUri: "place:type=6&sort=14&maxResults=10"
+    }
+  ];
+
+  let client = {
+    "guid": "root________",
+    "title": "",
+    "id": 1,
+    "type": "text/x-moz-place-container",
+    "children": [
+      {
+        "guid": "menu________",
+        "title": "foo",
+        "id": 1000,
+        "type": "text/x-moz-place-container",
+        "children": [
+          {
+            "guid": "bbbbbbbbbbbb",
+            "title": "bar",
+            "id": 1001,
+            "type": "text/x-moz-place",
+            "uri": "http://baz.com"
+          },
+          {
+            "guid": "cccccccccccc",
+            "title": "",
+            "id": 1002,
+            "annos": [{
+              "name": "Places/SmartBookmark",
+              "flags": 0,
+              "expires": 4,
+              "value": "RecentTags"
+            }],
+            "type": "text/x-moz-place",
+            "uri": "place:type=6&sort=14&maxResults=10"
+          }
+        ]
+      }
+    ]
+  };
+  return {server, client};
+}
+
+
+add_task(async function test_cswc_valid() {
+  let {server, client} = getDummyServerAndClient();
+
+  let c = (await compareServerWithClient(server, client)).problemData;
+  equal(c.clientMissing.length, 0);
+  equal(c.serverMissing.length, 0);
+  equal(c.differences.length, 0);
+});
+
+add_task(async function test_cswc_serverMissing() {
+  let {server, client} = getDummyServerAndClient();
+  // remove c
+  server.pop();
+  server[0].children.pop();
+
+  let c = (await compareServerWithClient(server, client)).problemData;
+  deepEqual(c.serverMissing, ["cccccccccccc"]);
+  equal(c.clientMissing.length, 0);
+  deepEqual(c.structuralDifferences, [{id: "menu", differences: ["childGUIDs"]}]);
+});
+
+add_task(async function test_cswc_clientMissing() {
+  let {server, client} = getDummyServerAndClient();
+  client.children[0].children.pop();
+
+  let c = (await compareServerWithClient(server, client)).problemData;
+  deepEqual(c.clientMissing, ["cccccccccccc"]);
+  equal(c.serverMissing.length, 0);
+  deepEqual(c.structuralDifferences, [{id: "menu", differences: ["childGUIDs"]}]);
+});
+
+add_task(async function test_cswc_differences() {
+  {
+    let {server, client} = getDummyServerAndClient();
+    client.children[0].children[0].title = "asdf";
+    let c = (await compareServerWithClient(server, client)).problemData;
+    equal(c.clientMissing.length, 0);
+    equal(c.serverMissing.length, 0);
+    deepEqual(c.differences, [{id: "bbbbbbbbbbbb", differences: ["title"]}]);
+  }
+
+  {
+    let {server, client} = getDummyServerAndClient();
+    server[2].type = "bookmark";
+    let c = (await compareServerWithClient(server, client)).problemData;
+    equal(c.clientMissing.length, 0);
+    equal(c.serverMissing.length, 0);
+    deepEqual(c.differences, [{id: "cccccccccccc", differences: ["type"]}]);
+  }
+});
+
+add_task(async function test_cswc_differentURLs() {
+  let {server, client} = getDummyServerAndClient();
+  client.children[0].children.push({
+    guid: "dddddddddddd",
+    title: "Tag query",
+    "type": "text/x-moz-place",
+    "uri": "place:type=7&folder=80",
+  }, {
+    guid: "eeeeeeeeeeee",
+    title: "Firefox",
+    "type": "text/x-moz-place",
+    "uri": "http://getfirefox.com",
+  });
+  server.push({
+    id: "dddddddddddd",
+    parentid: "menu",
+    parentName: "foo",
+    title: "Tag query",
+    type: "query",
+    folderName: "taggy",
+    bmkUri: "place:type=7&folder=90",
+  }, {
+    id: "eeeeeeeeeeee",
+    parentid: "menu",
+    parentName: "foo",
+    title: "Firefox",
+    type: "bookmark",
+    bmkUri: "https://mozilla.org/firefox",
+  });
+
+  let c = (await compareServerWithClient(server, client)).problemData;
+  equal(c.differences.length, 1);
+  deepEqual(c.differences, [{
+    id: "eeeeeeeeeeee",
+    differences: ["bmkUri"],
+  }]);
+});
+
+add_task(async function test_cswc_serverUnexpected() {
+  let {server, client} = getDummyServerAndClient();
+  client.children.push({
+    "guid": "dddddddddddd",
+    "title": "",
+    "id": 2000,
+    "annos": [{
+      "name": "places/excludeFromBackup",
+      "flags": 0,
+      "expires": 4,
+      "value": 1
+    }, {
+      "name": "PlacesOrganizer/OrganizerFolder",
+      "flags": 0,
+      "expires": 4,
+      "value": 7
+    }],
+    "type": "text/x-moz-place-container",
+    "children": [{
+      "guid": "eeeeeeeeeeee",
+      "title": "History",
+      "annos": [{
+        "name": "places/excludeFromBackup",
+        "flags": 0,
+        "expires": 4,
+        "value": 1
+      }, {
+        "name": "PlacesOrganizer/OrganizerQuery",
+        "flags": 0,
+        "expires": 4,
+        "value": "History"
+      }],
+      "type": "text/x-moz-place",
+      "uri": "place:type=3&sort=4"
+    }]
+  });
+  server.push({
+    id: "dddddddddddd",
+    parentid: "places",
+    parentName: "",
+    title: "",
+    type: "folder",
+    children: ["eeeeeeeeeeee"]
+  }, {
+    id: "eeeeeeeeeeee",
+    parentid: "dddddddddddd",
+    parentName: "",
+    title: "History",
+    type: "query",
+    bmkUri: "place:type=3&sort=4"
+  });
+
+  let c = (await compareServerWithClient(server, client)).problemData;
+  equal(c.clientMissing.length, 0);
+  equal(c.serverMissing.length, 0);
+  equal(c.serverUnexpected.length, 2);
+  deepEqual(c.serverUnexpected, ["dddddddddddd", "eeeeeeeeeeee"]);
+});
+
+add_task(async function test_cswc_clientCycles() {
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.menuGuid,
+    children: [{
+      // A query for the menu, referenced by its local ID instead of
+      // `BOOKMARKS_MENU`. This should be reported as a cycle.
+      guid: "dddddddddddd",
+      url: `place:folder=${PlacesUtils.bookmarksMenuFolderId}`,
+      title: "Bookmarks Menu",
+    }, {
+      // A query that references the menu, but excludes itself, so it can't
+      // form a cycle.
+      guid: "iiiiiiiiiiii",
+      url: `place:folder=BOOKMARKS_MENU&folder=UNFILED_BOOKMARKS&` +
+           `folder=TOOLBAR&queryType=1&sort=12&maxResults=10&` +
+           `excludeQueries=1`,
+      title: "Recently Bookmarked",
+    }],
+  });
+
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.toolbarGuid,
+    children: [{
+      guid: "eeeeeeeeeeee",
+      type: PlacesUtils.bookmarks.TYPE_FOLDER,
+      children: [{
+        // A query for the toolbar in a subfolder. This should still be reported
+        // as a cycle.
+        guid: "ffffffffffff",
+        url: "place:folder=TOOLBAR&sort=3",
+        title: "Bookmarks Toolbar",
+      }],
+    }],
+  });
+
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.unfiledGuid,
+    children: [{
+      // A query for the menu. This shouldn't be reported as a cycle, since it
+      // references a different root.
+      guid: "gggggggggggg",
+      url: "place:folder=BOOKMARKS_MENU&sort=5",
+      title: "Bookmarks Menu",
+    }],
+  });
+
+  await PlacesUtils.bookmarks.insertTree({
+    guid: PlacesUtils.bookmarks.mobileGuid,
+    children: [{
+      // A query referencing multiple roots, one of which forms a cycle by
+      // referencing mobile. This is extremely unlikely, but it's cheap to
+      // detect, so we still report it.
+      guid: "hhhhhhhhhhhh",
+      url: "place:folder=TOOLBAR&folder=MOBILE_BOOKMARKS&folder=UNFILED_BOOKMARKS&sort=1",
+      title: "Toolbar, Mobile, Unfiled",
+    }],
+  });
+
+  let clientTree = await PlacesUtils.promiseBookmarksTree("", {
+    includeItemIds: true
+  });
+
+  let c = (await compareServerWithClient([], clientTree)).problemData;
+  deepEqual(c.clientCycles, [
+    ["menu", "dddddddddddd"],
+    ["toolbar", "eeeeeeeeeeee", "ffffffffffff"],
+    ["mobile", "hhhhhhhhhhhh"],
+  ]);
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/test/unit/test_doctor.js
@@ -0,0 +1,188 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { Doctor, REPAIR_ADVANCE_PERIOD } = Cu.import("resource://sync-repair/doctor.jsm", {});
+Cu.import("resource://gre/modules/Services.jsm");
+
+initTestLogging("Trace");
+
+function mockDoctor(mocks) {
+  // Clone the object and put mocks in that.
+  return Object.assign({}, Doctor, mocks);
+}
+
+add_task(async function test_validation_interval() {
+  let now = 1000;
+  let doctor = mockDoctor({
+    _now() {
+      // note that the function being mocked actually returns seconds.
+      return now;
+    },
+    getValidator() {
+      return {
+        validate(e) {
+          return {};
+        }
+      };
+    }
+  });
+
+  let engine = {
+    name: "test-engine",
+  };
+
+  // setup prefs which enable test-engine validation.
+  Services.prefs.setBoolPref("services.sync.engine.test-engine.validation.enabled", true);
+  Services.prefs.setIntPref("services.sync.engine.test-engine.validation.percentageChance", 100);
+  Services.prefs.setIntPref("services.sync.engine.test-engine.validation.maxRecords", 1);
+  // And say we should validate every 10 seconds.
+  Services.prefs.setIntPref("services.sync.engine.test-engine.validation.interval", 10);
+
+  deepEqual(doctor._getEnginesToValidate([engine]), {
+    "test-engine": {
+      engine,
+      maxRecords: 1,
+    }
+  });
+  // We haven't advanced the timestamp, so we should not validate again.
+  deepEqual(doctor._getEnginesToValidate([engine]), {});
+  // Advance our clock by 11 seconds.
+  now += 11;
+  // We should validate again.
+  deepEqual(doctor._getEnginesToValidate([engine]), {
+    "test-engine": {
+      engine,
+      maxRecords: 1,
+    }
+  });
+});
+
+add_task(async function test_repairs_start() {
+  let repairStarted = false;
+  let problems = {
+    missingChildren: ["a", "b", "c"],
+  };
+  let validator = {
+    validate(engine) {
+      return problems;
+    },
+    canValidate() {
+      return Promise.resolve(true);
+    }
+  };
+  let engine = {
+    name: "test-engine"
+  };
+  let requestor = {
+    async startRepairs(validationInfo, flowID) {
+      ok(flowID, "got a flow ID");
+      equal(validationInfo, problems);
+      repairStarted = true;
+      return true;
+    },
+    tryServerOnlyRepairs() {
+      return false;
+    }
+  };
+  let doctor = mockDoctor({
+    _getEnginesToValidate(recentlySyncedEngines) {
+      deepEqual(recentlySyncedEngines, [engine]);
+      return {
+        "test-engine": { engine, maxRecords: -1 }
+      };
+    },
+    _getRepairRequestor(engineName) {
+      equal(engineName, engine.name);
+      return requestor;
+    },
+    _shouldRepair(e) {
+      return true;
+    },
+    _getValidator() {
+      return validator;
+    }
+  });
+  let promiseValidationDone = promiseOneObserver("weave:engine:validate:finish");
+  await doctor.consult([engine]);
+  await promiseValidationDone;
+  ok(repairStarted);
+});
+
+add_task(async function test_repairs_advanced_daily() {
+  let repairCalls = 0;
+  let requestor = {
+    async continueRepairs() {
+      repairCalls++;
+    },
+    tryServerOnlyRepairs() {
+      return false;
+    }
+  };
+  // start now at just after REPAIR_ADVANCE_PERIOD so we do a a first one.
+  let now = REPAIR_ADVANCE_PERIOD + 1;
+  let doctor = mockDoctor({
+    _getEnginesToValidate() {
+      return {}; // no validations.
+    },
+    _runValidators() {
+      // do nothing.
+    },
+    _getAllRepairRequestors() {
+      return {
+        foo: requestor,
+      };
+    },
+    _now() {
+      return now;
+    },
+  });
+  await doctor.consult();
+  equal(repairCalls, 1);
+  now += 10; // 10 seconds later...
+  await doctor.consult();
+  // should not have done another repair yet - it's too soon.
+  equal(repairCalls, 1);
+  // advance our pretend clock by the advance period (eg, day)
+  now += REPAIR_ADVANCE_PERIOD;
+  await doctor.consult();
+  // should have done another repair
+  equal(repairCalls, 2);
+});
+
+add_task(async function test_repairs_skip_if_cant_vaidate() {
+  let validator = {
+    canValidate() {
+      return Promise.resolve(false);
+    },
+    validate() {
+      ok(false, "Shouldn't validate");
+    }
+  };
+  let engine = {
+    name: "test-engine"
+  };
+  let requestor = {
+    async startRepairs(validationInfo, flowID) {
+      ok(false, "Never should start repairs");
+    },
+    tryServerOnlyRepairs() {
+      return false;
+    }
+  };
+  let doctor = mockDoctor({
+    _getEnginesToValidate(recentlySyncedEngines) {
+      deepEqual(recentlySyncedEngines, [engine]);
+      return {
+        "test-engine": { engine, maxRecords: -1 }
+      };
+    },
+    _getRepairRequestor(engineName) {
+      equal(engineName, engine.name);
+      return requestor;
+    },
+    _getValidator() {
+      return validator;
+    }
+  });
+  await doctor.consult([engine]);
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/test/unit/test_form_validator.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.import("resource://services-sync/engines/forms.js");
+Cu.import("resource://services-sync/main.js");
+Cu.import("resource://sync-repair/form_validator.jsm");
+
+function getDummyServerAndClient() {
+  return {
+    server: [
+      {
+        id: "11111",
+        guid: "11111",
+        name: "foo",
+        fieldname: "foo",
+        value: "bar",
+      },
+      {
+        id: "22222",
+        guid: "22222",
+        name: "foo2",
+        fieldname: "foo2",
+        value: "bar2",
+      },
+      {
+        id: "33333",
+        guid: "33333",
+        name: "foo3",
+        fieldname: "foo3",
+        value: "bar3",
+      },
+    ],
+    client: [
+      {
+        id: "11111",
+        guid: "11111",
+        name: "foo",
+        fieldname: "foo",
+        value: "bar",
+      },
+      {
+        id: "22222",
+        guid: "22222",
+        name: "foo2",
+        fieldname: "foo2",
+        value: "bar2",
+      },
+      {
+        id: "33333",
+        guid: "33333",
+        name: "foo3",
+        fieldname: "foo3",
+        value: "bar3",
+      }
+    ]
+  };
+}
+
+function newFormValidator() {
+  let validator = new FormValidator();
+  // Engines aren't registered with engineManager yet.
+  validator.engine = new FormEngine(Weave.Service);
+  return validator;
+}
+
+add_task(async function test_valid() {
+  let { server, client } = getDummyServerAndClient();
+  let validator = newFormValidator();
+  let { problemData, clientRecords, records, deletedRecords } =
+      await validator.compareClientWithServer(client, server);
+  equal(clientRecords.length, 3);
+  equal(records.length, 3);
+  equal(deletedRecords.length, 0);
+  deepEqual(problemData, validator.emptyProblemData());
+});
+
+
+add_task(async function test_formValidatorIgnoresMissingClients() {
+  // Since history form records are not deleted from the server, the
+  // |FormValidator| shouldn't set the |missingClient| flag in |problemData|.
+  let { server, client } = getDummyServerAndClient();
+  client.pop();
+
+  let validator = newFormValidator();
+  let { problemData, clientRecords, records, deletedRecords } =
+      await validator.compareClientWithServer(client, server);
+
+  equal(clientRecords.length, 2);
+  equal(records.length, 3);
+  equal(deletedRecords.length, 0);
+
+  let expected = validator.emptyProblemData();
+  deepEqual(problemData, expected);
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/test/unit/test_password_validator.js
@@ -0,0 +1,147 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.import("resource://services-sync/engines/passwords.js");
+Cu.import("resource://sync-repair/password_validator.jsm");
+
+function getDummyServerAndClient() {
+  return {
+    server: [
+      {
+        id: "11111",
+        guid: "11111",
+        hostname: "https://www.11111.com",
+        formSubmitURL: "https://www.11111.com/login",
+        password: "qwerty123",
+        passwordField: "pass",
+        username: "foobar",
+        usernameField: "user",
+        httpRealm: null,
+      },
+      {
+        id: "22222",
+        guid: "22222",
+        hostname: "https://www.22222.org",
+        formSubmitURL: "https://www.22222.org/login",
+        password: "hunter2",
+        passwordField: "passwd",
+        username: "baz12345",
+        usernameField: "user",
+        httpRealm: null,
+      },
+      {
+        id: "33333",
+        guid: "33333",
+        hostname: "https://www.33333.com",
+        formSubmitURL: "https://www.33333.com/login",
+        password: "p4ssw0rd",
+        passwordField: "passwad",
+        username: "quux",
+        usernameField: "user",
+        httpRealm: null,
+      },
+    ],
+    client: [
+      {
+        id: "11111",
+        guid: "11111",
+        hostname: "https://www.11111.com",
+        formSubmitURL: "https://www.11111.com/login",
+        password: "qwerty123",
+        passwordField: "pass",
+        username: "foobar",
+        usernameField: "user",
+        httpRealm: null,
+      },
+      {
+        id: "22222",
+        guid: "22222",
+        hostname: "https://www.22222.org",
+        formSubmitURL: "https://www.22222.org/login",
+        password: "hunter2",
+        passwordField: "passwd",
+        username: "baz12345",
+        usernameField: "user",
+        httpRealm: null,
+
+      },
+      {
+        id: "33333",
+        guid: "33333",
+        hostname: "https://www.33333.com",
+        formSubmitURL: "https://www.33333.com/login",
+        password: "p4ssw0rd",
+        passwordField: "passwad",
+        username: "quux",
+        usernameField: "user",
+        httpRealm: null,
+      }
+    ]
+  };
+}
+
+add_task(async function test_valid() {
+  let { server, client } = getDummyServerAndClient();
+  let validator = new PasswordValidator();
+  let { problemData, clientRecords, records, deletedRecords } =
+      await validator.compareClientWithServer(client, server);
+  equal(clientRecords.length, 3);
+  equal(records.length, 3);
+  equal(deletedRecords.length, 0);
+  deepEqual(problemData, validator.emptyProblemData());
+});
+
+add_task(async function test_missing() {
+  let validator = new PasswordValidator();
+  {
+    let { server, client } = getDummyServerAndClient();
+
+    client.pop();
+
+    let { problemData, clientRecords, records, deletedRecords } =
+        await validator.compareClientWithServer(client, server);
+
+    equal(clientRecords.length, 2);
+    equal(records.length, 3);
+    equal(deletedRecords.length, 0);
+
+    let expected = validator.emptyProblemData();
+    expected.clientMissing.push("33333");
+    deepEqual(problemData, expected);
+  }
+  {
+    let { server, client } = getDummyServerAndClient();
+
+    server.pop();
+
+    let { problemData, clientRecords, records, deletedRecords } =
+        await validator.compareClientWithServer(client, server);
+
+    equal(clientRecords.length, 3);
+    equal(records.length, 2);
+    equal(deletedRecords.length, 0);
+
+    let expected = validator.emptyProblemData();
+    expected.serverMissing.push("33333");
+    deepEqual(problemData, expected);
+  }
+});
+
+
+add_task(async function test_deleted() {
+  let { server, client } = getDummyServerAndClient();
+  let deletionRecord = { id: "444444", guid: "444444", deleted: true };
+
+  server.push(deletionRecord);
+  let validator = new PasswordValidator();
+
+  let { problemData, clientRecords, records, deletedRecords } =
+      await validator.compareClientWithServer(client, server);
+
+  equal(clientRecords.length, 3);
+  equal(records.length, 4);
+  deepEqual(deletedRecords, [deletionRecord]);
+
+  let expected = validator.emptyProblemData();
+  deepEqual(problemData, expected);
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/sync-repair/test/unit/xpcshell.ini
@@ -0,0 +1,16 @@
+[DEFAULT]
+firefox-appdir = browser
+head = head.js ../../../../../services/sync/tests/unit/head_appinfo.js ../../../../../services/common/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_helpers.js ../../../../../services/sync/tests/unit/head_http_server.js
+[test_bookmark_validator.js]
+[test_form_validator.js]
+[test_password_validator.js]
+[test_doctor.js]
+[test_bookmark_repair.js]
+skip-if = release_or_beta
+run-sequentially = Frequent timeouts, bug 1395148
+[test_bookmark_repair_requestor.js]
+# Repair is enabled only on Aurora and Nightly
+skip-if = release_or_beta
+[test_bookmark_repair_responder.js]
+skip-if = release_or_beta
+run-sequentially = Frequent timeouts, bug 1395148
deleted file mode 100644
--- a/services/sync/modules/bookmark_repair.js
+++ /dev/null
@@ -1,754 +0,0 @@
-/* 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";
-
-const Cu = Components.utils;
-
-this.EXPORTED_SYMBOLS = ["BookmarkRepairRequestor", "BookmarkRepairResponder"];
-
-Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/Preferences.jsm");
-Cu.import("resource://gre/modules/Log.jsm");
-Cu.import("resource://services-sync/util.js");
-Cu.import("resource://services-sync/collection_repair.js");
-Cu.import("resource://services-sync/constants.js");
-Cu.import("resource://services-sync/resource.js");
-Cu.import("resource://services-sync/doctor.js");
-Cu.import("resource://services-sync/telemetry.js");
-Cu.import("resource://services-common/async.js");
-Cu.import("resource://services-common/utils.js");
-
-XPCOMUtils.defineLazyModuleGetter(this, "PlacesSyncUtils",
-                                  "resource://gre/modules/PlacesSyncUtils.jsm");
-
-const log = Log.repository.getLogger("Sync.Engine.Bookmarks.Repair");
-
-const PREF_BRANCH = "services.sync.repairs.bookmarks.";
-
-// How long should we wait after sending a repair request before we give up?
-const RESPONSE_INTERVAL_TIMEOUT = 60 * 60 * 24 * 3; // 3 days
-
-// The maximum number of IDs we will request to be repaired. Beyond this
-// number we assume that trying to repair may do more harm than good and may
-// ask another client to wipe the server and reupload everything. Bug 1341972
-// is tracking that work.
-const MAX_REQUESTED_IDS = 1000;
-
-class AbortRepairError extends Error {
-  constructor(reason) {
-    super();
-    this.reason = reason;
-  }
-}
-
-// The states we can be in.
-const STATE = Object.freeze({
-  NOT_REPAIRING: "",
-
-  // We need to try to find another client to use.
-  NEED_NEW_CLIENT: "repair.need-new-client",
-
-  // We've sent the first request to a client.
-  SENT_REQUEST: "repair.sent",
-
-  // We've retried a request to a client.
-  SENT_SECOND_REQUEST: "repair.sent-again",
-
-  // There were no problems, but we've gone as far as we can.
-  FINISHED: "repair.finished",
-
-  // We've found an error that forces us to abort this entire repair cycle.
-  ABORTED: "repair.aborted",
-});
-
-// The preferences we use to hold our state.
-const PREF = Object.freeze({
-  // If a repair is in progress, this is the generated GUID for the "flow ID".
-  REPAIR_ID: "flowID",
-
-  // The IDs we are currently trying to obtain via the repair process, space sep'd.
-  REPAIR_MISSING_IDS: "ids",
-
-  // The ID of the client we're currently trying to get the missing items from.
-  REPAIR_CURRENT_CLIENT: "currentClient",
-
-  // The IDs of the clients we've previously tried to get the missing items
-  // from, space sep'd.
-  REPAIR_PREVIOUS_CLIENTS: "previousClients",
-
-  // The time, in seconds, when we initiated the most recent client request.
-  REPAIR_WHEN: "when",
-
-  // Our current state.
-  CURRENT_STATE: "state",
-});
-
-class BookmarkRepairRequestor extends CollectionRepairRequestor {
-  constructor(service = null) {
-    super(service);
-    this.prefs = new Preferences(PREF_BRANCH);
-  }
-
-  /* Check if any other clients connected to our account are current performing
-     a repair. A thin wrapper which exists mainly for mocking during tests.
-  */
-  anyClientsRepairing(flowID) {
-    return Doctor.anyClientsRepairing(this.service, "bookmarks", flowID);
-  }
-
-  /* Return a set of IDs we should request.
-  */
-  getProblemIDs(validationInfo) {
-    // Set of ids of "known bad records". Many of the validation issues will
-    // report duplicates -- if the server is missing a record, it is unlikely
-    // to cause only a single problem.
-    let ids = new Set();
-
-    // Note that we allow any of the validation problem fields to be missing so
-    // that tests have a slightly easier time, hence the `|| []` in each loop.
-
-    // Missing children records when the parent exists but a child doesn't.
-    for (let { parent, child } of validationInfo.problems.missingChildren || []) {
-      // We can't be sure if the child is missing or our copy of the parent is
-      // wrong, so request both
-      ids.add(parent);
-      ids.add(child);
-    }
-    if (ids.size > MAX_REQUESTED_IDS) {
-      return ids; // might as well give up here - we aren't going to repair.
-    }
-
-    // Orphans are when the child exists but the parent doesn't.
-    // This could either be a problem in the child (it's wrong about the node
-    // that should be its parent), or the parent could simply be missing.
-    for (let { parent, id } of validationInfo.problems.orphans || []) {
-      // Request both, to handle both cases
-      ids.add(id);
-      ids.add(parent);
-    }
-    if (ids.size > MAX_REQUESTED_IDS) {
-      return ids; // might as well give up here - we aren't going to repair.
-    }
-
-    // Entries where we have the parent but we have a record from the server that
-    // claims the child was deleted.
-    for (let { parent, child } of validationInfo.problems.deletedChildren || []) {
-      // Request both, since we don't know if it's a botched deletion or revival
-      ids.add(parent);
-      ids.add(child);
-    }
-    if (ids.size > MAX_REQUESTED_IDS) {
-      return ids; // might as well give up here - we aren't going to repair.
-    }
-
-    // Entries where the child references a parent that we don't have, but we
-    // have a record from the server that claims the parent was deleted.
-    for (let { parent, child } of validationInfo.problems.deletedParents || []) {
-      // Request both, since we don't know if it's a botched deletion or revival
-      ids.add(parent);
-      ids.add(child);
-    }
-    if (ids.size > MAX_REQUESTED_IDS) {
-      return ids; // might as well give up here - we aren't going to repair.
-    }
-
-    // Cases where the parent and child disagree about who the parent is.
-    for (let { parent, child } of validationInfo.problems.parentChildMismatches || []) {
-      // Request both, since we don't know which is right.
-      ids.add(parent);
-      ids.add(child);
-    }
-    if (ids.size > MAX_REQUESTED_IDS) {
-      return ids; // might as well give up here - we aren't going to repair.
-    }
-
-    // Cases where multiple parents reference a child. We re-request both the
-    // child, and all the parents that think they own it. This may be more than
-    // we need, but I don't think we can safely make the assumption that the
-    // child is right.
-    for (let { parents, child } of validationInfo.problems.multipleParents || []) {
-      for (let parent of parents) {
-        ids.add(parent);
-      }
-      ids.add(child);
-    }
-
-    return ids;
-  }
-
-  _countServerOnlyFixableProblems(validationInfo) {
-    const fixableProblems = ["clientMissing", "serverMissing", "serverDeleted"];
-    return fixableProblems.reduce((numProblems, problemLabel) => {
-      return numProblems + validationInfo.problems[problemLabel].length;
-    }, 0);
-  }
-
-  tryServerOnlyRepairs(validationInfo) {
-    if (this._countServerOnlyFixableProblems(validationInfo) == 0) {
-      return false;
-    }
-    let engine = this.service.engineManager.get("bookmarks");
-    for (let id of validationInfo.problems.serverMissing) {
-      engine.addForWeakUpload(id);
-    }
-    let toFetch = engine.toFetch.concat(validationInfo.problems.clientMissing,
-                                        validationInfo.problems.serverDeleted);
-    engine.toFetch = Array.from(new Set(toFetch));
-    return true;
-  }
-
-  /* See if the repairer is willing and able to begin a repair process given
-     the specified validation information.
-     Returns true if a repair was started and false otherwise.
-  */
-  async startRepairs(validationInfo, flowID) {
-    if (this._currentState != STATE.NOT_REPAIRING) {
-      log.info(`Can't start a repair - repair with ID ${this._flowID} is already in progress`);
-      return false;
-    }
-
-    let ids = this.getProblemIDs(validationInfo);
-    if (ids.size > MAX_REQUESTED_IDS) {
-      log.info("Not starting a repair as there are over " + MAX_REQUESTED_IDS + " problems");
-      let extra = { flowID, reason: `too many problems: ${ids.size}` };
-      this.service.recordTelemetryEvent("repair", "aborted", undefined, extra);
-      return false;
-    }
-
-    if (ids.size == 0) {
-      log.info("Not starting a repair as there are no problems");
-      return false;
-    }
-
-    if (this.anyClientsRepairing()) {
-      log.info("Can't start repair, since other clients are already repairing bookmarks");
-      let extra = { flowID, reason: "other clients repairing" };
-      this.service.recordTelemetryEvent("repair", "aborted", undefined, extra);
-      return false;
-    }
-
-    log.info(`Starting a repair, looking for ${ids.size} missing item(s)`);
-    // setup our prefs to indicate we are on our way.
-    this._flowID = flowID;
-    this._currentIDs = Array.from(ids);
-    this._currentState = STATE.NEED_NEW_CLIENT;
-    this.service.recordTelemetryEvent("repair", "started", undefined, { flowID, numIDs: ids.size.toString() });
-    return this.continueRepairs();
-  }
-
-  /* Work out what state our current repair request is in, and whether it can
-     proceed to a new state.
-     Returns true if we could continue the repair - even if the state didn't
-     actually move. Returns false if we aren't actually repairing.
-  */
-  async continueRepairs(response = null) {
-    // Note that "ABORTED" and "FINISHED" should never be current when this
-    // function returns - this function resets to NOT_REPAIRING in those cases.
-    if (this._currentState == STATE.NOT_REPAIRING) {
-      return false;
-    }
-
-    let state, newState;
-    let abortReason;
-    // we loop until the state doesn't change - but enforce a max of 10 times
-    // to prevent errors causing infinite loops.
-    for (let i = 0; i < 10; i++) {
-      state = this._currentState;
-      log.info("continueRepairs starting with state", state);
-      try {
-        newState = await this._continueRepairs(state, response);
-        log.info("continueRepairs has next state", newState);
-      } catch (ex) {
-        if (!(ex instanceof AbortRepairError)) {
-          throw ex;
-        }
-        log.info(`Repair has been aborted: ${ex.reason}`);
-        newState = STATE.ABORTED;
-        abortReason = ex.reason;
-      }
-
-      if (newState == STATE.ABORTED) {
-        break;
-      }
-
-      this._currentState = newState;
-      Services.prefs.savePrefFile(null); // flush prefs.
-      if (state == newState) {
-        break;
-      }
-    }
-    if (state != newState) {
-      log.error("continueRepairs spun without getting a new state");
-    }
-    if (newState == STATE.FINISHED || newState == STATE.ABORTED) {
-      let object = newState == STATE.FINISHED ? "finished" : "aborted";
-      let extra = {
-        flowID: this._flowID,
-        numIDs: this._currentIDs.length.toString(),
-      };
-      if (abortReason) {
-        extra.reason = abortReason;
-      }
-      this.service.recordTelemetryEvent("repair", object, undefined, extra);
-      // reset our state and flush our prefs.
-      this.prefs.resetBranch();
-      Services.prefs.savePrefFile(null); // flush prefs.
-    }
-    return true;
-  }
-
-  async _continueRepairs(state, response = null) {
-    if (this.anyClientsRepairing(this._flowID)) {
-      throw new AbortRepairError("other clients repairing");
-    }
-    switch (state) {
-      case STATE.SENT_REQUEST:
-      case STATE.SENT_SECOND_REQUEST:
-        let flowID = this._flowID;
-        let clientID = this._currentRemoteClient;
-        if (!clientID) {
-          throw new AbortRepairError(`In state ${state} but have no client IDs listed`);
-        }
-        if (response) {
-          // We got an explicit response - let's see how we went.
-          state = this._handleResponse(state, response);
-          break;
-        }
-        // So we've sent a request - and don't yet have a response. See if the
-        // client we sent it to has removed it from its list (ie, whether it
-        // has synced since we wrote the request.)
-        let client = this.service.clientsEngine.remoteClient(clientID);
-        if (!client) {
-          // hrmph - the client has disappeared.
-          log.info(`previously requested client "${clientID}" has vanished - moving to next step`);
-          state = STATE.NEED_NEW_CLIENT;
-          let extra = {
-            deviceID: this.service.identity.hashedDeviceID(clientID),
-            flowID,
-          };
-          this.service.recordTelemetryEvent("repair", "abandon", "missing", extra);
-          break;
-        }
-        if ((await this._isCommandPending(clientID, flowID))) {
-          // So the command we previously sent is still queued for the client
-          // (ie, that client is yet to have synced). Let's see if we should
-          // give up on that client.
-          let lastRequestSent = this.prefs.get(PREF.REPAIR_WHEN);
-          let timeLeft = lastRequestSent + RESPONSE_INTERVAL_TIMEOUT - this._now();
-          if (timeLeft <= 0) {
-            log.info(`previous request to client ${clientID} is pending, but has taken too long`);
-            state = STATE.NEED_NEW_CLIENT;
-            // XXX - should we remove the command?
-            let extra = {
-              deviceID: this.service.identity.hashedDeviceID(clientID),
-              flowID,
-            };
-            this.service.recordTelemetryEvent("repair", "abandon", "silent", extra);
-            break;
-          }
-          // Let's continue to wait for that client to respond.
-          log.trace(`previous request to client ${clientID} has ${timeLeft} seconds before we give up on it`);
-          break;
-        }
-        // The command isn't pending - if this was the first request, we give
-        // it another go (as that client may have cleared the command but is yet
-        // to complete the sync)
-        // XXX - note that this is no longer true - the responders don't remove
-        // their command until they have written a response. This might mean
-        // we could drop the entire STATE.SENT_SECOND_REQUEST concept???
-        if (state == STATE.SENT_REQUEST) {
-          log.info(`previous request to client ${clientID} was removed - trying a second time`);
-          state = STATE.SENT_SECOND_REQUEST;
-          await this._writeRequest(clientID);
-        } else {
-          // this was the second time around, so give up on this client
-          log.info(`previous 2 requests to client ${clientID} were removed - need a new client`);
-          state = STATE.NEED_NEW_CLIENT;
-        }
-        break;
-
-      case STATE.NEED_NEW_CLIENT:
-        // We need to find a new client to request.
-        let newClientID = this._findNextClient();
-        if (!newClientID) {
-          state = STATE.FINISHED;
-          break;
-        }
-        this._addToPreviousRemoteClients(this._currentRemoteClient);
-        this._currentRemoteClient = newClientID;
-        await this._writeRequest(newClientID);
-        state = STATE.SENT_REQUEST;
-        break;
-
-      case STATE.ABORTED:
-        break; // our caller will take the abort action.
-
-      case STATE.FINISHED:
-        break;
-
-      case STATE.NOT_REPAIRING:
-        // No repair is in progress. This is a common case, so only log trace.
-        log.trace("continue repairs called but no repair in progress.");
-        break;
-
-      default:
-        log.error(`continue repairs finds itself in an unknown state ${state}`);
-        state = STATE.ABORTED;
-        break;
-
-    }
-    return state;
-  }
-
-  /* Handle being in the SENT_REQUEST or SENT_SECOND_REQUEST state with an
-     explicit response.
-  */
-  _handleResponse(state, response) {
-    let clientID = this._currentRemoteClient;
-    let flowID = this._flowID;
-
-    if (response.flowID != flowID || response.clientID != clientID ||
-        response.request != "upload") {
-      log.info("got a response to a different repair request", response);
-      // hopefully just a stale request that finally came in (either from
-      // an entirely different repair flow, or from a client we've since
-      // given up on.) It doesn't mean we need to abort though...
-      return state;
-    }
-    // Pull apart the response and see if it provided everything we asked for.
-    let remainingIDs = Array.from(CommonUtils.difference(this._currentIDs, response.ids));
-    log.info(`repair response from ${clientID} provided "${response.ids}", remaining now "${remainingIDs}"`);
-    this._currentIDs = remainingIDs;
-    if (remainingIDs.length) {
-      // try a new client for the remaining ones.
-      state = STATE.NEED_NEW_CLIENT;
-    } else {
-      state = STATE.FINISHED;
-    }
-    // record telemetry about this
-    let extra = {
-      deviceID: this.service.identity.hashedDeviceID(clientID),
-      flowID,
-      numIDs: response.ids.length.toString(),
-    };
-    this.service.recordTelemetryEvent("repair", "response", "upload", extra);
-    return state;
-  }
-
-  /* Issue a repair request to a specific client.
-  */
-  async _writeRequest(clientID) {
-    log.trace("writing repair request to client", clientID);
-    let ids = this._currentIDs;
-    if (!ids) {
-      throw new AbortRepairError("Attempting to write a request, but there are no IDs");
-    }
-    let flowID = this._flowID;
-    // Post a command to that client.
-    let request = {
-      collection: "bookmarks",
-      request: "upload",
-      requestor: this.service.clientsEngine.localID,
-      ids,
-      flowID,
-    };
-    await this.service.clientsEngine.sendCommand("repairRequest", [request], clientID, { flowID });
-    this.prefs.set(PREF.REPAIR_WHEN, Math.floor(this._now()));
-    // record telemetry about this
-    let extra = {
-      deviceID: this.service.identity.hashedDeviceID(clientID),
-      flowID,
-      numIDs: ids.length.toString(),
-    };
-    this.service.recordTelemetryEvent("repair", "request", "upload", extra);
-  }
-
-  _findNextClient() {
-    let alreadyDone = this._getPreviousRemoteClients();
-    alreadyDone.push(this._currentRemoteClient);
-    let remoteClients = this.service.clientsEngine.remoteClients;
-    // we want to consider the most-recently synced clients first.
-    remoteClients.sort((a, b) => b.serverLastModified - a.serverLastModified);
-    for (let client of remoteClients) {
-      log.trace("findNextClient considering", client);
-      if (alreadyDone.indexOf(client.id) == -1 && this._isSuitableClient(client)) {
-        return client.id;
-      }
-    }
-    log.trace("findNextClient found no client");
-    return null;
-  }
-
-  /* Is the passed client record suitable as a repair responder?
-  */
-  _isSuitableClient(client) {
-    // filter only desktop firefox running > 53 (ie, any 54)
-    return (client.type == DEVICE_TYPE_DESKTOP &&
-            Services.vc.compare(client.version, 53) > 0);
-  }
-
-  /* Is our command still in the "commands" queue for the specific client?
-  */
-  async _isCommandPending(clientID, flowID) {
-    // getClientCommands() is poorly named - it's only outgoing commands
-    // from us we have yet to write. For our purposes, we want to check
-    // them and commands previously written (which is in .commands)
-    let clientCommands = await this.service.clientsEngine.getClientCommands(clientID);
-    let commands = [...clientCommands,
-                    ...this.service.clientsEngine.remoteClient(clientID).commands || []];
-    for (let command of commands) {
-      if (command.command != "repairRequest" || command.args.length != 1) {
-        continue;
-      }
-      let arg = command.args[0];
-      if (arg.collection == "bookmarks" && arg.request == "upload" &&
-          arg.flowID == flowID) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  // accessors for our prefs.
-  get _currentState() {
-    return this.prefs.get(PREF.CURRENT_STATE, STATE.NOT_REPAIRING);
-  }
-  set _currentState(newState) {
-    this.prefs.set(PREF.CURRENT_STATE, newState);
-  }
-
-  get _currentIDs() {
-    let ids = this.prefs.get(PREF.REPAIR_MISSING_IDS, "");
-    return ids.length ? ids.split(" ") : [];
-  }
-  set _currentIDs(arrayOfIDs) {
-    this.prefs.set(PREF.REPAIR_MISSING_IDS, arrayOfIDs.join(" "));
-  }
-
-  get _currentRemoteClient() {
-    return this.prefs.get(PREF.REPAIR_CURRENT_CLIENT);
-  }
-  set _currentRemoteClient(clientID) {
-    this.prefs.set(PREF.REPAIR_CURRENT_CLIENT, clientID);
-  }
-
-  get _flowID() {
-    return this.prefs.get(PREF.REPAIR_ID);
-  }
-  set _flowID(val) {
-    this.prefs.set(PREF.REPAIR_ID, val);
-  }
-
-  // use a function for this pref to offer somewhat sane semantics.
-  _getPreviousRemoteClients() {
-    let alreadyDone = this.prefs.get(PREF.REPAIR_PREVIOUS_CLIENTS, "");
-    return alreadyDone.length ? alreadyDone.split(" ") : [];
-  }
-  _addToPreviousRemoteClients(clientID) {
-    let arrayOfClientIDs = this._getPreviousRemoteClients();
-    arrayOfClientIDs.push(clientID);
-    this.prefs.set(PREF.REPAIR_PREVIOUS_CLIENTS, arrayOfClientIDs.join(" "));
-  }
-
-  /* Used for test mocks.
-  */
-  _now() {
-    // We use the server time, which is SECONDS
-    return Resource.serverTime;
-  }
-}
-
-/* An object that responds to repair requests initiated by some other device.
-*/
-class BookmarkRepairResponder extends CollectionRepairResponder {
-  async repair(request, rawCommand) {
-    if (request.request != "upload") {
-      await this._abortRepair(request, rawCommand,
-                              `Don't understand request type '${request.request}'`);
-      return;
-    }
-
-    // Note that we don't try and guard against multiple repairs being in
-    // progress as we don't do anything too smart that could cause problems,
-    // but just upload items. If we get any smarter we should re-think this
-    // (but when we do, note that checking this._currentState isn't enough as
-    // this responder is not a singleton)
-
-    this._currentState = {
-      request,
-      rawCommand,
-      processedCommand: false,
-      ids: [],
-    };
-
-    try {
-      let engine = this.service.engineManager.get("bookmarks");
-      let { toUpload, toDelete } = await this._fetchItemsToUpload(request);
-
-      if (toUpload.size || toDelete.size) {
-        log.debug(`repair request will upload ${toUpload.size} items and delete ${toDelete.size} items`);
-        // whew - now add these items to the tracker "weakly" (ie, they will not
-        // persist in the case of a restart, but that's OK - we'll then end up here
-        // again) and also record them in the response we send back.
-        for (let id of toUpload) {
-          engine.addForWeakUpload(id);
-          this._currentState.ids.push(id);
-        }
-        for (let id of toDelete) {
-          engine.addForWeakUpload(id, { forceTombstone: true });
-          this._currentState.ids.push(id);
-        }
-
-        // We have arranged for stuff to be uploaded, so wait until that's done.
-        Svc.Obs.add("weave:engine:sync:uploaded", this.onUploaded, this);
-        // and record in telemetry that we got this far - just incase we never
-        // end up doing the upload for some obscure reason.
-        let eventExtra = {
-          flowID: request.flowID,
-          numIDs: this._currentState.ids.length.toString(),
-        };
-        this.service.recordTelemetryEvent("repairResponse", "uploading", undefined, eventExtra);
-      } else {
-        // We were unable to help with the repair, so report that we are done.
-        await this._finishRepair();
-      }
-    } catch (ex) {
-      if (Async.isShutdownException(ex)) {
-        // this repair request will be tried next time.
-        throw ex;
-      }
-      // On failure, we still write a response so the requestor knows to move
-      // on, but we record the failure reason in telemetry.
-      log.error("Failed to respond to the repair request", ex);
-      this._currentState.failureReason = SyncTelemetry.transformError(ex);
-      await this._finishRepair();
-    }
-  }
-
-  async _fetchItemsToUpload(request) {
-    let toUpload = new Set(); // items we will upload.
-    let toDelete = new Set(); // items we will delete.
-
-    let requested = new Set(request.ids);
-
-    let engine = this.service.engineManager.get("bookmarks");
-    // Determine every item that may be impacted by the requested IDs - eg,
-    // this may include children if a requested ID is a folder.
-    // Turn an array of { recordId, syncable } into a map of recordId -> syncable.
-    let repairable = await PlacesSyncUtils.bookmarks.fetchRecordIdsForRepair(request.ids);
-    if (repairable.length == 0) {
-      // server will get upset if we request an empty set, and we can't do
-      // anything in that case, so bail now.
-      return { toUpload, toDelete };
-    }
-
-    // which of these items exist on the server?
-    let itemSource = engine.itemSource();
-    itemSource.ids = repairable.map(item => item.recordId);
-    log.trace(`checking the server for items`, itemSource.ids);
-    let itemsResponse = await itemSource.get();
-    // If the response failed, don't bother trying to parse the output.
-    // Throwing here means we abort the repair, which isn't ideal for transient
-    // errors (eg, no network, 500 service outage etc), but we don't currently
-    // have a sane/safe way to try again later (we'd need to implement a kind
-    // of timeout, otherwise we might end up retrying forever and never remove
-    // our request command.) Bug 1347805.
-    if (!itemsResponse.success) {
-      throw new Error(`request for server IDs failed: ${itemsResponse.status}`);
-    }
-    let existRemotely = new Set(JSON.parse(itemsResponse));
-    // We need to be careful about handing the requested items:
-    // * If the item exists locally but isn't in the tree of items we sync
-    //   (eg, it might be a left-pane item or similar, we write a tombstone.
-    // * If the item exists locally as a folder, we upload the folder and any
-    //   children which don't exist on the server. (Note that we assume the
-    //   parents *do* exist)
-    // Bug 1343101 covers additional issues we might repair in the future.
-    for (let { recordId: id, syncable } of repairable) {
-      if (requested.has(id)) {
-        if (syncable) {
-          log.debug(`repair request to upload item '${id}' which exists locally; uploading`);
-          toUpload.add(id);
-        } else {
-          // explicitly requested and not syncable, so tombstone.
-          log.debug(`repair request to upload item '${id}' but it isn't under a syncable root; writing a tombstone`);
-          toDelete.add(id);
-        }
-      // The item wasn't explicitly requested - only upload if it is syncable
-      // and doesn't exist on the server.
-      } else if (syncable && !existRemotely.has(id)) {
-        log.debug(`repair request found related item '${id}' which isn't on the server; uploading`);
-        toUpload.add(id);
-      } else if (!syncable && existRemotely.has(id)) {
-        log.debug(`repair request found non-syncable related item '${id}' on the server; writing a tombstone`);
-        toDelete.add(id);
-      } else {
-        log.debug(`repair request found related item '${id}' which we will not upload; ignoring`);
-      }
-    }
-    return { toUpload, toDelete };
-  }
-
-  onUploaded(subject, data) {
-    if (data != "bookmarks") {
-      return;
-    }
-    Svc.Obs.remove("weave:engine:sync:uploaded", this.onUploaded, this);
-    if (subject.failed) {
-      return;
-    }
-    log.debug(`bookmarks engine has uploaded stuff - creating a repair response`, subject);
-    Async.promiseSpinningly(this._finishRepair());
-  }
-
-  async _finishRepair() {
-    let clientsEngine = this.service.clientsEngine;
-    let flowID = this._currentState.request.flowID;
-    let response = {
-      request: this._currentState.request.request,
-      collection: "bookmarks",
-      clientID: clientsEngine.localID,
-      flowID,
-      ids: this._currentState.ids,
-    };
-    let clientID = this._currentState.request.requestor;
-    await clientsEngine.sendCommand("repairResponse", [response], clientID, { flowID });
-    // and nuke the request from our client.
-    await clientsEngine.removeLocalCommand(this._currentState.rawCommand);
-    let eventExtra = {
-      flowID,
-      numIDs: response.ids.length.toString(),
-    };
-    if (this._currentState.failureReason) {
-      // *sob* - recording this in "extra" means the value must be a string of
-      // max 85 chars.
-      eventExtra.failureReason = JSON.stringify(this._currentState.failureReason).substring(0, 85);
-      this.service.recordTelemetryEvent("repairResponse", "failed", undefined, eventExtra);
-    } else {
-      this.service.recordTelemetryEvent("repairResponse", "finished", undefined, eventExtra);
-    }
-    this._currentState = null;
-  }
-
-  async _abortRepair(request, rawCommand, why) {
-    log.warn(`aborting repair request: ${why}`);
-    await this.service.clientsEngine.removeLocalCommand(rawCommand);
-    // record telemetry for this.
-    let eventExtra = {
-      flowID: request.flowID,
-      reason: why,
-    };
-    this.service.recordTelemetryEvent("repairResponse", "aborted", undefined, eventExtra);
-    // We could also consider writing a response here so the requestor can take
-    // some immediate action rather than timing out, but we abort only in cases
-    // that should be rare, so let's wait and see what telemetry tells us.
-  }
-}
-
-/* Exposed in case another module needs to understand our state.
-*/
-BookmarkRepairRequestor.STATE = STATE;
-BookmarkRepairRequestor.PREF = PREF;
deleted file mode 100644
--- a/services/sync/modules/collection_repair.js
+++ /dev/null
@@ -1,139 +0,0 @@
-/* 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";
-
-const Cu = Components.utils;
-
-Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://services-sync/main.js");
-
-XPCOMUtils.defineLazyModuleGetter(this, "BookmarkRepairRequestor",
-  "resource://services-sync/bookmark_repair.js");
-
-this.EXPORTED_SYMBOLS = ["getRepairRequestor", "getAllRepairRequestors",
-                         "CollectionRepairRequestor",
-                         "getRepairResponder",
-                         "CollectionRepairResponder"];
-
-// The individual requestors/responders, lazily loaded.
-const REQUESTORS = {
-  bookmarks: ["bookmark_repair.js", "BookmarkRepairRequestor"],
-};
-
-const RESPONDERS = {
-  bookmarks: ["bookmark_repair.js", "BookmarkRepairResponder"],
-};
-
-// Should we maybe enforce the requestors being a singleton?
-function _getRepairConstructor(which, collection) {
-  if (!(collection in which)) {
-    return null;
-  }
-  let [modname, symbolname] = which[collection];
-  let ns = {};
-  Cu.import("resource://services-sync/" + modname, ns);
-  return ns[symbolname];
-}
-
-function getRepairRequestor(collection) {
-  let ctor = _getRepairConstructor(REQUESTORS, collection);
-  if (!ctor) {
-    return null;
-  }
-  return new ctor();
-}
-
-function getAllRepairRequestors() {
-  let result = {};
-  for (let collection of Object.keys(REQUESTORS)) {
-    let ctor = _getRepairConstructor(REQUESTORS, collection);
-    result[collection] = new ctor();
-  }
-  return result;
-}
-
-function getRepairResponder(collection) {
-  let ctor = _getRepairConstructor(RESPONDERS, collection);
-  if (!ctor) {
-    return null;
-  }
-  return new ctor();
-}
-
-// The abstract classes.
-class CollectionRepairRequestor {
-  constructor(service = null) {
-    // allow service to be mocked in tests.
-    this.service = service || Weave.Service;
-  }
-
-  /* Try to resolve some issues with the server without involving other clients.
-     Returns true if we repaired some items.
-
-     @param   validationInfo       {Object}
-              The validation info as returned by the collection's validator.
-
-  */
-  tryServerOnlyRepairs(validationInfo) {
-    return false;
-  }
-
-  /* See if the repairer is willing and able to begin a repair process given
-     the specified validation information.
-     Returns true if a repair was started and false otherwise.
-
-     @param   validationInfo       {Object}
-              The validation info as returned by the collection's validator.
-
-     @param   flowID               {String}
-              A guid that uniquely identifies this repair process for this
-              collection, and which should be sent to any requestors and
-              reported in telemetry.
-
-  */
-  async startRepairs(validationInfo, flowID) {
-    throw new Error("not implemented");
-  }
-
-  /* Work out what state our current repair request is in, and whether it can
-     proceed to a new state.
-     Returns true if we could continue the repair - even if the state didn't
-     actually move. Returns false if we aren't actually repairing.
-
-     @param   responseInfo       {Object}
-              An optional response to a previous repair request, as returned
-              by a remote repair responder.
-
-  */
-  async continueRepairs(responseInfo = null) {
-    throw new Error("not implemented");
-  }
-}
-
-class CollectionRepairResponder {
-  constructor(service = null) {
-    // allow service to be mocked in tests.
-    this.service = service || Weave.Service;
-  }
-
-  /* Take some action in response to a repair request. Returns a promise that
-     resolves once the repair process has started, or rejects if there
-     was an error starting the repair.
-
-     Note that when the promise resolves the repair is not yet complete - at
-     some point in the future the repair will auto-complete, at which time
-     |rawCommand| will be removed from the list of client commands for this
-     client.
-
-     @param   request       {Object}
-              The repair request as sent by another client.
-
-     @param   rawCommand    {Object}
-              The command object as stored in the clients engine, and which
-              will be automatically removed once a repair completes.
-  */
-  async repair(request, rawCommand) {
-    throw new Error("not implemented");
-  }
-}
deleted file mode 100644
--- a/services/sync/modules/collection_validator.js
+++ /dev/null
@@ -1,231 +0,0 @@
-/* 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";
-
-const Cu = Components.utils;
-Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-
-XPCOMUtils.defineLazyModuleGetter(this, "Async",
-                                  "resource://services-common/async.js");
-
-
-this.EXPORTED_SYMBOLS = ["CollectionValidator", "CollectionProblemData"];
-
-class CollectionProblemData {
-  constructor() {
-    this.missingIDs = 0;
-    this.duplicates = [];
-    this.clientMissing = [];
-    this.serverMissing = [];
-    this.serverDeleted = [];
-    this.serverUnexpected = [];
-    this.differences = [];
-  }
-
-  /**
-   * Produce a list summarizing problems found. Each entry contains {name, count},
-   * where name is the field name for the problem, and count is the number of times
-   * the problem was encountered.
-   *
-   * Validation has failed if all counts are not 0.
-   */
-  getSummary() {
-    return [
-      { name: "clientMissing", count: this.clientMissing.length },
-      { name: "serverMissing", count: this.serverMissing.length },
-      { name: "serverDeleted", count: this.serverDeleted.length },
-      { name: "serverUnexpected", count: this.serverUnexpected.length },
-      { name: "differences", count: this.differences.length },
-      { name: "missingIDs", count: this.missingIDs },
-      { name: "duplicates", count: this.duplicates.length }
-    ];
-  }
-}
-
-class CollectionValidator {
-  // Construct a generic collection validator. This is intended to be called by
-  // subclasses.
-  // - name: Name of the engine
-  // - idProp: Property that identifies a record. That is, if a client and server
-  //   record have the same value for the idProp property, they should be
-  //   compared against eachother.
-  // - props: Array of properties that should be compared
-  constructor(name, idProp, props) {
-    this.name = name;
-    this.props = props;
-    this.idProp = idProp;
-
-    // This property deals with the fact that form history records are never
-    // deleted from the server. The FormValidator subclass needs to ignore the
-    // client missing records, and it uses this property to achieve it -
-    // (Bug 1354016).
-    this.ignoresMissingClients = false;
-  }
-
-  // Should a custom ProblemData type be needed, return it here.
-  emptyProblemData() {
-    return new CollectionProblemData();
-  }
-
-  async getServerItems(engine) {
-    let collection = engine.itemSource();
-    let collectionKey = engine.service.collectionKeys.keyForCollection(engine.name);
-    collection.full = true;
-    let result = await collection.getBatched();
-    if (!result.response.success) {
-      throw result.response;
-    }
-    let maybeYield = Async.jankYielder();
-    let cleartexts = [];
-    for (let record of result.records) {
-      await maybeYield();
-      await record.decrypt(collectionKey);
-      cleartexts.push(record.cleartext);
-    }
-    return cleartexts;
-  }
-
-  // Should return a promise that resolves to an array of client items.
-  getClientItems() {
-    return Promise.reject("Must implement");
-  }
-
-  /**
-   * Can we guarantee validation will fail with a reason that isn't actually a
-   * problem? For example, if we know there are pending changes left over from
-   * the last sync, this should resolve to false. By default resolves to true.
-   */
-  async canValidate() {
-    return true;
-  }
-
-  // Turn the client item into something that can be compared with the server item,
-  // and is also safe to mutate.
-  normalizeClientItem(item) {
-    return Cu.cloneInto(item, {});
-  }
-
-  // Turn the server item into something that can be easily compared with the client
-  // items.
-  async normalizeServerItem(item) {
-    return item;
-  }
-
-  // Return whether or not a server item should be present on the client. Expected
-  // to be overridden.
-  clientUnderstands(item) {
-    return true;
-  }
-
-  // Return whether or not a client item should be present on the server. Expected
-  // to be overridden
-  syncedByClient(item) {
-    return true;
-  }
-
-  // Compare the server item and the client item, and return a list of property
-  // names that are different. Can be overridden if needed.
-  getDifferences(client, server) {
-    let differences = [];
-    for (let prop of this.props) {
-      let clientProp = client[prop];
-      let serverProp = server[prop];
-      if ((clientProp || "") !== (serverProp || "")) {
-        differences.push(prop);
-      }
-    }
-    return differences;
-  }
-
-  // Returns an object containing
-  //   problemData: an instance of the class returned by emptyProblemData(),
-  //   clientRecords: Normalized client records
-  //   records: Normalized server records,
-  //   deletedRecords: Array of ids that were marked as deleted by the server.
-  async compareClientWithServer(clientItems, serverItems) {
-    let maybeYield = Async.jankYielder();
-    const clientRecords = [];
-    for (let item of clientItems) {
-      await maybeYield();
-      clientRecords.push(this.normalizeClientItem(item));
-    }
-    const serverRecords = [];
-    for (let item of serverItems) {
-      await maybeYield();
-      serverRecords.push((await this.normalizeServerItem(item)));
-    }
-    let problems = this.emptyProblemData();
-    let seenServer = new Map();
-    let serverDeleted = new Set();
-    let allRecords = new Map();
-
-    for (let record of serverRecords) {
-      let id = record[this.idProp];
-      if (!id) {
-        ++problems.missingIDs;
-        continue;
-      }
-      if (record.deleted) {
-        serverDeleted.add(record);
-      } else {
-        let possibleDupe = seenServer.get(id);
-        if (possibleDupe) {
-          problems.duplicates.push(id);
-        } else {
-          seenServer.set(id, record);
-          allRecords.set(id, { server: record, client: null, });
-        }
-        record.understood = this.clientUnderstands(record);
-      }
-    }
-
-    let seenClient = new Map();
-    for (let record of clientRecords) {
-      let id = record[this.idProp];
-      record.shouldSync = this.syncedByClient(record);
-      seenClient.set(id, record);
-      let combined = allRecords.get(id);
-      if (combined) {
-        combined.client = record;
-      } else {
-        allRecords.set(id, { client: record, server: null });
-      }
-    }
-
-    for (let [id, { server, client }] of allRecords) {
-      if (!client && !server) {
-        throw new Error("Impossible: no client or server record for " + id);
-      } else if (server && !client) {
-        if (!this.ignoresMissingClients && server.understood) {
-          problems.clientMissing.push(id);
-        }
-      } else if (client && !server) {
-        if (client.shouldSync) {
-          problems.serverMissing.push(id);
-        }
-      } else {
-        if (!client.shouldSync) {
-          if (!problems.serverUnexpected.includes(id)) {
-            problems.serverUnexpected.push(id);
-          }
-          continue;
-        }
-        let differences = this.getDifferences(client, server);
-        if (differences && differences.length) {
-          problems.differences.push({ id, differences });
-        }
-      }
-    }
-    return {
-      problemData: problems,
-      clientRecords,
-      records: serverRecords,
-      deletedRecords: [...serverDeleted]
-    };
-  }
-}
-
-// Default to 0, some engines may override.
-CollectionValidator.prototype.version = 0;
deleted file mode 100644
--- a/services/sync/modules/doctor.js
+++ /dev/null
@@ -1,260 +0,0 @@
-/* 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/. */
-
-// A doctor for our collections. She can be asked to make a consultation, and
-// may just diagnose an issue without attempting to cure it, may diagnose and
-// attempt to cure, or may decide she is overworked and underpaid.
-// Or something - naming is hard :)
-
-"use strict";
-
-this.EXPORTED_SYMBOLS = ["Doctor"];
-
-const Cu = Components.utils;
-
-Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/Log.jsm");
-Cu.import("resource://services-common/async.js");
-Cu.import("resource://services-common/observers.js");
-Cu.import("resource://services-sync/service.js");
-Cu.import("resource://services-sync/resource.js");
-
-Cu.import("resource://services-sync/util.js");
-XPCOMUtils.defineLazyModuleGetter(this, "getRepairRequestor",
-  "resource://services-sync/collection_repair.js");
-XPCOMUtils.defineLazyModuleGetter(this, "getAllRepairRequestors",
-  "resource://services-sync/collection_repair.js");
-
-const log = Log.repository.getLogger("Sync.Doctor");
-
-this.REPAIR_ADVANCE_PERIOD = 86400; // 1 day
-
-this.Doctor = {
-  anyClientsRepairing(service, collection, ignoreFlowID = null) {
-    if (!service || !service.clientsEngine) {
-      log.info("Missing clients engine, assuming we're in test code");
-      return false;
-    }
-    return service.clientsEngine.remoteClients.some(client =>
-      client.commands && client.commands.some(command => {
-        if (command.command != "repairResponse" && command.command != "repairRequest") {
-          return false;
-        }
-        if (!command.args || command.args.length != 1) {
-          return false;
-        }
-        if (command.args[0].collection != collection) {
-          return false;
-        }
-        if (ignoreFlowID != null && command.args[0].flowID == ignoreFlowID) {
-          return false;
-        }
-        return true;
-      })
-    );
-  },
-
-  async consult(recentlySyncedEngines) {
-    if (!Services.telemetry.canRecordBase) {
-      log.info("Skipping consultation: telemetry reporting is disabled");
-      return;
-    }
-
-    let engineInfos = this._getEnginesToValidate(recentlySyncedEngines);
-
-    await this._runValidators(engineInfos);
-
-    // We are called at the end of a sync, which is a good time to periodically
-    // check each repairer to see if it can advance.
-    if (this._now() - this.lastRepairAdvance > REPAIR_ADVANCE_PERIOD) {
-      try {
-        for (let [collection, requestor] of Object.entries(this._getAllRepairRequestors())) {
-          try {
-            let advanced = await requestor.continueRepairs();
-            log.info(`${collection} reparier ${advanced ? "advanced" : "did not advance"}.`);
-          } catch (ex) {
-            if (Async.isShutdownException(ex)) {
-              throw ex;
-            }
-            log.error(`${collection} repairer failed`, ex);
-          }
-        }
-      } finally {
-        this.lastRepairAdvance = Math.floor(this._now());
-      }
-    }
-  },
-
-  _getEnginesToValidate(recentlySyncedEngines) {
-    let result = {};
-    for (let e of recentlySyncedEngines) {
-      let prefPrefix = `engine.${e.name}.`;
-      if (!Svc.Prefs.get(prefPrefix + "validation.enabled", false)) {
-        log.info(`Skipping check of ${e.name} - disabled via preferences`);
-        continue;
-      }
-      // Check the last validation time for the engine.
-      let lastValidation = Svc.Prefs.get(prefPrefix + "validation.lastTime", 0);
-      let validationInterval = Svc.Prefs.get(prefPrefix + "validation.interval");
-      let nowSeconds = this._now();
-
-      if (nowSeconds - lastValidation < validationInterval) {
-        log.info(`Skipping validation of ${e.name}: too recent since last validation attempt`);
-        continue;
-      }
-      // Update the time now, even if we decline to actually perform a
-      // validation. We don't want to check the rest of these more frequently
-      // than once a day.
-      Svc.Prefs.set(prefPrefix + "validation.lastTime", Math.floor(nowSeconds));
-
-      // Validation only occurs a certain percentage of the time.
-      let validationProbability = Svc.Prefs.get(prefPrefix + "validation.percentageChance", 0) / 100.0;
-      if (validationProbability < Math.random()) {
-        log.info(`Skipping validation of ${e.name}: Probability threshold not met`);
-        continue;
-      }
-
-      let maxRecords = Svc.Prefs.get(prefPrefix + "validation.maxRecords");
-      if (!maxRecords) {
-        log.info(`Skipping validation of ${e.name}: No maxRecords specified`);
-        continue;
-      }
-      // OK, so this is a candidate - the final decision will be based on the
-      // number of records actually found.
-      result[e.name] = { engine: e, maxRecords };
-    }
-    return result;
-  },
-
-  async _runValidators(engineInfos) {
-    if (Object.keys(engineInfos).length == 0) {
-      log.info("Skipping validation: no engines qualify");
-      return;
-    }
-
-    if (Object.values(engineInfos).filter(i => i.maxRecords != -1).length != 0) {
-      // at least some of the engines have maxRecord restrictions which require
-      // us to ask the server for the counts.
-      let countInfo = await this._fetchCollectionCounts();
-      for (let [engineName, recordCount] of Object.entries(countInfo)) {
-        if (engineName in engineInfos) {
-          engineInfos[engineName].recordCount = recordCount;
-        }
-      }
-    }
-
-    for (let [engineName, { engine, maxRecords, recordCount }] of Object.entries(engineInfos)) {
-      // maxRecords of -1 means "any number", so we can skip asking the server.
-      // Used for tests.
-      if (maxRecords >= 0 && recordCount > maxRecords) {
-        log.debug(`Skipping validation for ${engineName} because ` +
-                        `the number of records (${recordCount}) is greater ` +
-                        `than the maximum allowed (${maxRecords}).`);
-        continue;
-      }
-      let validator = engine.getValidator();
-      if (!validator) {
-        continue;
-      }
-
-      if (!await validator.canValidate()) {
-        log.debug(`Skipping validation for ${engineName} because validator.canValidate() is false`);
-        continue;
-      }
-
-      // Let's do it!
-      Services.console.logStringMessage(
-        `Sync is about to run a consistency check of ${engine.name}. This may be slow, and ` +
-        `can be controlled using the pref "services.sync.${engine.name}.validation.enabled".\n` +
-        `If you encounter any problems because of this, please file a bug.`);
-
-      // Make a new flowID just incase we end up starting a repair.
-      let flowID = Utils.makeGUID();
-      try {
-        // XXX - must get the flow ID to either the validator, or directly to
-        // telemetry. I guess it's probably OK to always record a flowID even
-        // if we don't end up repairing?
-        log.info(`Running validator for ${engine.name}`);
-        let result = await validator.validate(engine);
-        Observers.notify("weave:engine:validate:finish", result, engine.name);
-        // And see if we should repair.
-        await this._maybeCure(engine, result, flowID);
-      } catch (ex) {
-        if (Async.isShutdownException(ex)) {
-          throw ex;
-        }
-        log.error(`Failed to run validation on ${engine.name}!`, ex);
-        Observers.notify("weave:engine:validate:error", ex, engine.name);
-        // Keep validating -- there's no reason to think that a failure for one
-        // validator would mean the others will fail.
-      }
-    }
-  },
-
-  async _maybeCure(engine, validationResults, flowID) {
-    if (!this._shouldRepair(engine)) {
-      log.info(`Skipping repair of ${engine.name} - disabled via preferences`);
-      return;
-    }
-
-    let requestor = this._getRepairRequestor(engine.name);
-    let didStart = false;
-    if (requestor) {
-      if (requestor.tryServerOnlyRepairs(validationResults)) {
-        return; // TODO: It would be nice if we could request a validation to be
-                // done on next sync.
-      }
-      didStart = await requestor.startRepairs(validationResults, flowID);
-    }
-    log.info(`${didStart ? "did" : "didn't"} start a repair of ${engine.name} with flowID ${flowID}`);
-  },
-
-  _shouldRepair(engine) {
-    return Svc.Prefs.get(`engine.${engine.name}.repair.enabled`, false);
-  },
-
-  // mainly for mocking.
-  async _fetchCollectionCounts() {
-    let collectionCountsURL = Service.userBaseURL + "info/collection_counts";
-    try {
-      let infoResp = await Service._fetchInfo(collectionCountsURL);
-      if (!infoResp.success) {
-        log.error("Can't fetch collection counts: request to info/collection_counts responded with "
-                        + infoResp.status);
-        return {};
-      }
-      return infoResp.obj; // might throw because obj is a getter which parses json.
-    } catch (ex) {
-      if (Async.isShutdownException(ex)) {
-        throw ex;
-      }
-      // Not running validation is totally fine, so we just write an error log and return.
-      log.error("Caught error when fetching counts", ex);
-      return {};
-    }
-  },
-
-  get lastRepairAdvance() {
-    return Svc.Prefs.get("doctor.lastRepairAdvance", 0);
-  },
-
-  set lastRepairAdvance(value) {
-    Svc.Prefs.set("doctor.lastRepairAdvance", value);
-  },
-
-  // functions used so tests can mock them
-  _now() {
-    // We use the server time, which is SECONDS
-    return Resource.serverTime;
-  },
-
-  _getRepairRequestor(name) {
-    return getRepairRequestor(name);
-  },
-
-  _getAllRepairRequestors() {
-    return getAllRepairRequestors();
-  }
-};
--- a/services/sync/modules/engines.js
+++ b/services/sync/modules/engines.js
@@ -745,25 +745,16 @@ Engine.prototype = {
     this._tracker.ignoreAll = false;
     this._tracker.clearChangedIDs();
   },
 
   async wipeClient() {
     return this._notify("wipe-client", this.name, this._wipeClient)();
   },
 
-  /**
-   * If one exists, initialize and return a validator for this engine (which
-   * must have a `validate(engine)` method that returns a promise to an object
-   * with a getSummary method). Otherwise return null.
-   */
-  getValidator() {
-    return null;
-  },
-
   async finalize() {
     await this._tracker.finalize();
   },
 };
 
 this.SyncEngine = function SyncEngine(name, service) {
   Engine.call(this, name || "SyncEngine", service);
 
--- a/services/sync/modules/engines/addons.js
+++ b/services/sync/modules/engines/addons.js
@@ -42,25 +42,24 @@ Cu.import("resource://gre/modules/XPCOMU
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://services-sync/addonutils.js");
 Cu.import("resource://services-sync/addonsreconciler.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-sync/constants.js");
-Cu.import("resource://services-sync/collection_validator.js");
 Cu.import("resource://services-common/async.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AddonRepository",
                                   "resource://gre/modules/addons/AddonRepository.jsm");
 
-this.EXPORTED_SYMBOLS = ["AddonsEngine", "AddonValidator"];
+this.EXPORTED_SYMBOLS = ["AddonsEngine"];
 
 // 7 days in milliseconds.
 const PRUNE_ADDON_CHANGES_THRESHOLD = 60 * 60 * 24 * 7 * 1000;
 
 /**
  * AddonRecord represents the state of an add-on in an application.
  *
  * Each add-on has its own record for each application ID it is installed
@@ -734,69 +733,8 @@ AddonsTracker.prototype = {
     this.reconciler.addChangeListener(this);
   },
 
   stopTracking() {
     this.reconciler.removeChangeListener(this);
     this.reconciler.stopListening();
   },
 };
-
-class AddonValidator extends CollectionValidator {
-  constructor(engine = null) {
-    super("addons", "id", [
-      "addonID",
-      "enabled",
-      "applicationID",
-      "source"
-    ]);
-    this.engine = engine;
-  }
-
-  async getClientItems() {
-    const installed = await AddonManager.getAllAddons();
-    const addonsWithPendingOperation = await AddonManager.getAddonsWithOperationsByTypes(["extension", "theme"]);
-    // Addons pending install won't be in the first list, but addons pending
-    // uninstall/enable/disable will be in both lists.
-    let all = new Map(installed.map(addon => [addon.id, addon]));
-    for (let addon of addonsWithPendingOperation) {
-      all.set(addon.id, addon);
-    }
-    // Convert to an array since Map.prototype.values returns an iterable
-    return [...all.values()];
-  }
-
-  normalizeClientItem(item) {
-    let enabled = !item.userDisabled;
-    if (item.pendingOperations & AddonManager.PENDING_ENABLE) {
-      enabled = true;
-    } else if (item.pendingOperations & AddonManager.PENDING_DISABLE) {
-      enabled = false;
-    }
-    return {
-      enabled,
-      id: item.syncGUID,
-      addonID: item.id,
-      applicationID: Services.appinfo.ID,
-      source: "amo", // check item.foreignInstall?
-      original: item
-    };
-  }
-
-  async normalizeServerItem(item) {
-    let guid = await this.engine._findDupe(item);
-    if (guid) {
-      item.id = guid;
-    }
-    return item;
-  }
-
-  clientUnderstands(item) {
-    return item.applicationID === Services.appinfo.ID;
-  }
-
-  syncedByClient(item) {
-    return !item.original.hidden &&
-           !item.original.isSystem &&
-           !(item.original.pendingOperations & AddonManager.PENDING_UNINSTALL) &&
-           this.engine.isAddonSyncable(item.original, true);
-  }
-}
--- a/services/sync/modules/engines/bookmarks.js
+++ b/services/sync/modules/engines/bookmarks.js
@@ -13,18 +13,16 @@ var Cu = Components.utils;
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://services-common/async.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/util.js");
 
-XPCOMUtils.defineLazyModuleGetter(this, "BookmarkValidator",
-                                  "resource://services-sync/bookmark_validator.js");
 XPCOMUtils.defineLazyGetter(this, "PlacesBundle", () => {
   let bundleService = Cc["@mozilla.org/intl/stringbundle;1"]
                         .getService(Ci.nsIStringBundleService);
   return bundleService.createBundle("chrome://places/locale/places.properties");
 });
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
                                   "resource://gre/modules/PlacesUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PlacesSyncUtils",
@@ -625,20 +623,16 @@ BookmarksEngine.prototype = {
       child => !newChildren.has(child)) : [];
 
     // Some of the children in `order` might have been deleted, or moved to
     // other folders. `PlacesSyncUtils.bookmarks.order` ignores them.
     let order = newRecord.children ?
                 [...newRecord.children, ...missingChildren] : missingChildren;
     this._log.debug("Recording children of " + localRecord.id, order);
     this._store._childrenToOrder[localRecord.id] = order;
-  },
-
-  getValidator() {
-    return new BookmarkValidator();
   }
 };
 
 function BookmarksStore(name, engine) {
   Store.call(this, name, engine);
   this._itemsToDelete = new Set();
 }
 BookmarksStore.prototype = {
--- a/services/sync/modules/engines/clients.js
+++ b/services/sync/modules/engines/clients.js
@@ -747,55 +747,47 @@ ClientEngine.prototype = {
           case "displayURI":
             let [uri, clientId, title] = args;
             URIsToDisplay.push({ uri, clientId, title });
             break;
           case "repairResponse": {
             // When we send a repair request to another device that understands
             // it, that device will send a response indicating what it did.
             let response = args[0];
-            let requestor = getRepairRequestor(response.collection);
-            if (!requestor) {
-              this._log.warn("repairResponse for unknown collection", response);
-              break;
-            }
-            if (!(await requestor.continueRepairs(response))) {
-              this._log.warn("repairResponse couldn't continue the repair", response);
+            if (this.service.repairManager) {
+              await this.service.repairManager.handleRepairReponse(response, rawCommand);
             }
             break;
           }
           case "repairRequest": {
             // Another device has sent us a request to make some repair.
             let request = args[0];
-            let responder = getRepairResponder(request.collection);
-            if (!responder) {
-              this._log.warn("repairRequest for unknown collection", request);
-              break;
-            }
-            try {
-              if ((await responder.repair(request, rawCommand))) {
-                // We've started a repair - once that collection has synced it
-                // will write a "response" command and arrange for this repair
-                // request to be removed from the local command list - if we
-                // removed it now we might fail to write a response in cases of
-                // premature shutdown etc.
-                shouldRemoveCommand = false;
+            if (this.service.repairManager) {
+              try {
+                if ((await this.service.repairManager.handleRepairRequest(request, rawCommand))) {
+                  // We've started a repair - once that collection has synced it
+                  // will write a "response" command and arrange for this repair
+                  // request to be removed from the local command list - if we
+                  // removed it now we might fail to write a response in cases of
+                  // premature shutdown etc.
+                  shouldRemoveCommand = false;
+                }
+              } catch (ex) {
+                if (Async.isShutdownException(ex)) {
+                  // Let's assume this error was caused by the shutdown, so let
+                  // it try again next time.
+                  throw ex;
+                }
+                // otherwise there are no second chances - the command is removed
+                // and will not be tried again.
+                // (Note that this shouldn't be hit in the normal case - it's
+                // expected the responder will handle all reasonable failures and
+                // write a response indicating that it couldn't do what was asked.)
+                this._log.error("Failed to handle a repair request", ex);
               }
-            } catch (ex) {
-              if (Async.isShutdownException(ex)) {
-                // Let's assume this error was caused by the shutdown, so let
-                // it try again next time.
-                throw ex;
-              }
-              // otherwise there are no second chances - the command is removed
-              // and will not be tried again.
-              // (Note that this shouldn't be hit in the normal case - it's
-              // expected the responder will handle all reasonable failures and
-              // write a response indicating that it couldn't do what was asked.)
-              this._log.error("Failed to handle a repair request", ex);
             }
             break;
           }
           default:
             this._log.warn("Received an unknown command: " + command);
             break;
         }
         // Add the command to the "cleared" commands list
--- a/services/sync/modules/engines/forms.js
+++ b/services/sync/modules/engines/forms.js
@@ -1,24 +1,23 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-this.EXPORTED_SYMBOLS = ["FormEngine", "FormRec", "FormValidator"];
+this.EXPORTED_SYMBOLS = ["FormEngine", "FormRec"];
 
 var Cc = Components.classes;
 var Ci = Components.interfaces;
 var Cu = Components.utils;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-sync/constants.js");
-Cu.import("resource://services-sync/collection_validator.js");
 Cu.import("resource://services-common/async.js");
 Cu.import("resource://gre/modules/Log.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
                                   "resource://gre/modules/FormHistory.jsm");
 
 const FORMS_TTL = 3 * 365 * 24 * 60 * 60; // Three years in seconds.
 
 this.FormRec = function FormRec(collection, id) {
@@ -112,17 +111,25 @@ FormEngine.prototype = {
   syncPriority: 6,
 
   get prefName() {
     return "history";
   },
 
   async _findDupe(item) {
     return FormWrapper.getGUID(item.name, item.value);
-  }
+  },
+
+  // For the validator
+  _search(terms, searchData) {
+    return FormWrapper._search(terms, searchData);
+  },
+  _getGUID(name, value) {
+    return FormWrapper.getGUID(name, value);
+  },
 };
 
 function FormStore(name, engine) {
   Store.call(this, name, engine);
 }
 FormStore.prototype = {
   __proto__: Store.prototype,
 
@@ -246,61 +253,8 @@ FormTracker.prototype = {
 
   trackEntry(guid) {
     if (this.addChangedID(guid)) {
       this.score += SCORE_INCREMENT_MEDIUM;
     }
   },
 };
 
-
-class FormsProblemData extends CollectionProblemData {
-  getSummary() {
-    // We don't support syncing deleted form data, so "clientMissing" isn't a problem
-    return super.getSummary().filter(entry =>
-      entry.name !== "clientMissing");
-  }
-}
-
-class FormValidator extends CollectionValidator {
-  constructor() {
-    super("forms", "id", ["name", "value"]);
-    this.ignoresMissingClients = true;
-  }
-
-  emptyProblemData() {
-    return new FormsProblemData();
-  }
-
-  async getClientItems() {
-    return FormWrapper._search(["guid", "fieldname", "value"], {});
-  }
-
-  normalizeClientItem(item) {
-    return {
-      id: item.guid,
-      guid: item.guid,
-      name: item.fieldname,
-      fieldname: item.fieldname,
-      value: item.value,
-      original: item,
-    };
-  }
-
-  async normalizeServerItem(item) {
-    let res = Object.assign({
-      guid: item.id,
-      fieldname: item.name,
-      original: item,
-    }, item);
-    // Missing `name` or `value` causes the getGUID call to throw
-    if (item.name !== undefined && item.value !== undefined) {
-      let guid = await FormWrapper.getGUID(item.name, item.value);
-      if (guid) {
-        res.guid = guid;
-        res.id = guid;
-        res.duped = true;
-      }
-    }
-
-    return res;
-  }
-}
--- a/services/sync/modules/engines/passwords.js
+++ b/services/sync/modules/engines/passwords.js
@@ -1,20 +1,19 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-this.EXPORTED_SYMBOLS = ["PasswordEngine", "LoginRec", "PasswordValidator"];
+this.EXPORTED_SYMBOLS = ["PasswordEngine", "LoginRec"];
 
 var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/constants.js");
-Cu.import("resource://services-sync/collection_validator.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-common/async.js");
 
 const SYNCABLE_LOGIN_FIELDS = [
   // `nsILoginInfo` fields.
   "hostname",
   "formSubmitURL",
@@ -389,51 +388,8 @@ PasswordTracker.prototype = {
     }
     if (!this.addChangedID(login.guid)) {
       return false;
     }
     this.score += SCORE_INCREMENT_XLARGE;
     return true;
   },
 };
-
-class PasswordValidator extends CollectionValidator {
-  constructor() {
-    super("passwords", "id", [
-      "hostname",
-      "formSubmitURL",
-      "httpRealm",
-      "password",
-      "passwordField",
-      "username",
-      "usernameField",
-    ]);
-  }
-
-  getClientItems() {
-    let logins = Services.logins.getAllLogins({});
-    let syncHosts = Utils.getSyncCredentialsHosts();
-    let result = logins.map(l => l.QueryInterface(Ci.nsILoginMetaInfo))
-                       .filter(l => !syncHosts.has(l.hostname));
-    return Promise.resolve(result);
-  }
-
-  normalizeClientItem(item) {
-    return {
-      id: item.guid,
-      guid: item.guid,
-      hostname: item.hostname,
-      formSubmitURL: item.formSubmitURL,
-      httpRealm: item.httpRealm,
-      password: item.password,
-      passwordField: item.passwordField,
-      username: item.username,
-      usernameField: item.usernameField,
-      original: item,
-    };
-  }
-
-  async normalizeServerItem(item) {
-    return Object.assign({ guid: item.id }, item);
-  }
-}
-
-
--- a/services/sync/modules/service.js
+++ b/services/sync/modules/service.js
@@ -155,16 +155,29 @@ Sync11Service.prototype = {
 
     // Generate and cache various URLs under the storage API for this user
     this.infoURL = this.userBaseURL + "info/collections";
     this.storageURL = this.userBaseURL + "storage/";
     this.metaURL = this.storageURL + "meta/global";
     this.cryptoKeysURL = this.storageURL + CRYPTO_COLLECTION + "/" + KEYS_WBO;
   },
 
+  // A reference to the sync-repair extension singleton. The extension takes care of
+  // attaching itself via setRepairManager() and clearRepairManager()
+  repairManager: null,
+  setRepairManager(manager) {
+    this._log.info("Manager for sync-repair registered with sync service");
+    this.repairManager = manager;
+  },
+
+  clearRepairManager() {
+    this._log.info("Manager for sync-repair disabled");
+    this.repairManager = null;
+  },
+
   _checkCrypto: function _checkCrypto() {
     let ok = false;
 
     try {
       let iv = Weave.Crypto.generateRandomIV();
       if (iv.length == 24)
         ok = true;
 
--- a/services/sync/modules/stages/enginesync.js
+++ b/services/sync/modules/stages/enginesync.js
@@ -10,18 +10,16 @@ this.EXPORTED_SYMBOLS = ["EngineSynchron
 
 var {utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-common/async.js");
-XPCOMUtils.defineLazyModuleGetter(this, "Doctor",
-                                  "resource://services-sync/doctor.js");
 
 /**
  * Perform synchronization of engines.
  *
  * This was originally split out of service.js. The API needs lots of love.
  */
 this.EngineSynchronizer = function EngineSynchronizer(service) {
   this._log = Log.repository.getLogger("Sync.Synchronizer");
@@ -174,17 +172,19 @@ EngineSynchronizer.prototype = {
           await this.service.uploadMetaGlobal(meta);
           delete meta.isNew;
           delete meta.changed;
         } catch (error) {
           this._log.error("Unable to upload meta/global. Leaving marked as new.");
         }
       }
 
-      await Doctor.consult(enginesToValidate);
+      if (this.service.repairManager) {
+        await this.service.repairManager.consultDoctor(enginesToValidate);
+      }
 
       // If there were no sync engine failures
       if (this.service.status.service != SYNC_FAILED_PARTIAL) {
         Svc.Prefs.set("lastSync", new Date().toString());
         this.service.status.sync = SYNC_SUCCEEDED;
       }
     } finally {
       Svc.Prefs.reset("firstSync");
--- a/services/sync/moz.build
+++ b/services/sync/moz.build
@@ -14,23 +14,18 @@ XPCSHELL_TESTS_MANIFESTS += ['tests/unit
 EXTRA_COMPONENTS += [
     'SyncComponents.manifest',
     'Weave.js',
 ]
 
 EXTRA_JS_MODULES['services-sync'] += [
     'modules/addonsreconciler.js',
     'modules/addonutils.js',
-    'modules/bookmark_repair.js',
-    'modules/bookmark_validator.js',
     'modules/browserid_identity.js',
-    'modules/collection_repair.js',
-    'modules/collection_validator.js',
     'modules/constants.js',
-    'modules/doctor.js',
     'modules/engines.js',
     'modules/keys.js',
     'modules/main.js',
     'modules/policies.js',
     'modules/record.js',
     'modules/resource.js',
     'modules/service.js',
     'modules/status.js',
--- a/services/sync/tests/unit/head_helpers.js
+++ b/services/sync/tests/unit/head_helpers.js
@@ -25,17 +25,21 @@ add_task(async function head_setup() {
     await this.Service.promiseInitialized;
   }
 });
 
 // ================================================
 // Load mocking/stubbing library, sinon
 // docs: http://sinonjs.org/releases/v2.3.2/
 Cu.import("resource://gre/modules/Timer.jsm");
-Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js", this);
+XPCOMUtils.defineLazyGetter(this, "sinon", () => {
+  Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js", this)
+  return sinon;
+});
+
 /* globals sinon */
 // ================================================
 
 XPCOMUtils.defineLazyGetter(this, "SyncPingSchema", function() {
   let ns = {};
   Cu.import("resource://gre/modules/FileUtils.jsm", ns);
   Cu.import("resource://gre/modules/NetUtil.jsm", ns);
   let stream = Cc["@mozilla.org/network/file-input-stream;1"]
@@ -511,17 +515,22 @@ async function registerRotaryEngine() {
   await Service.engineManager.register(RotaryEngine);
   let engine = Service.engineManager.get("rotary");
   engine.enabled = true;
 
   return { engine, tracker: engine._tracker };
 }
 
 // Set the validation prefs to attempt validation every time to avoid non-determinism.
-function enableValidationPrefs() {
+function forceBookmarkValidation() {
+  if (!Service.repairManager) {
+    let ns = {};
+    Cu.import("resource://sync-repair/repair_manager.jsm", ns);
+    Service.setRepairManager(new ns.RepairManager());
+  }
   Svc.Prefs.set("engine.bookmarks.validation.interval", 0);
   Svc.Prefs.set("engine.bookmarks.validation.percentageChance", 100);
   Svc.Prefs.set("engine.bookmarks.validation.maxRecords", -1);
   Svc.Prefs.set("engine.bookmarks.validation.enabled", true);
 }
 
 async function serverForEnginesWithKeys(users, engines, callback) {
   // Generate and store a fake default key bundle to avoid resetting the client
--- a/services/sync/tests/unit/test_bookmark_duping.js
+++ b/services/sync/tests/unit/test_bookmark_duping.js
@@ -3,17 +3,19 @@
 
 Cu.import("resource://services-common/async.js");
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/engines/bookmarks.js");
 Cu.import("resource://services-sync/service.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://testing-common/services/sync/utils.js");
-Cu.import("resource://services-sync/bookmark_validator.js");
+// XXX - This... doesn't work. The loadSubscript dance doesn't seem
+// to either, is there a good way to do this?
+Cu.import("resource://sync-repair/bookmark_validator.jsm");
 
 const bms = PlacesUtils.bookmarks;
 
 add_task(async function setup() {
   initTestLogging("Trace");
   await Service.engineManager.unregister("bookmarks");
 });
 
deleted file mode 100644
--- a/services/sync/tests/unit/test_bookmark_repair.js
+++ /dev/null
@@ -1,516 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
- * http://creativecommons.org/publicdomain/zero/1.0/ */
-
-// Tests the bookmark repair requestor and responder end-to-end (ie, without
-// many mocks)
-Cu.import("resource://gre/modules/Log.jsm");
-Cu.import("resource://gre/modules/osfile.jsm");
-Cu.import("resource://services-sync/bookmark_repair.js");
-Cu.import("resource://services-sync/constants.js");
-Cu.import("resource://services-sync/doctor.js");
-Cu.import("resource://services-sync/service.js");
-Cu.import("resource://services-sync/engines/clients.js");
-Cu.import("resource://services-sync/engines/bookmarks.js");
-Cu.import("resource://testing-common/services/sync/utils.js");
-
-const LAST_BOOKMARK_SYNC_PREFS = [
-  "bookmarks.lastSync",
-  "bookmarks.lastSyncLocal",
-];
-
-const BOOKMARK_REPAIR_STATE_PREFS = [
-  "client.GUID",
-  "doctor.lastRepairAdvance",
-  ...LAST_BOOKMARK_SYNC_PREFS,
-  ...Object.values(BookmarkRepairRequestor.PREF).map(name =>
-    `repairs.bookmarks.${name}`
-  ),
-];
-
-let clientsEngine;
-let bookmarksEngine;
-var recordedEvents = [];
-
-add_task(async function setup() {
-  clientsEngine = Service.clientsEngine;
-  clientsEngine.ignoreLastModifiedOnProcessCommands = true;
-  bookmarksEngine = Service.engineManager.get("bookmarks");
-
-  await generateNewKeys(Service.collectionKeys);
-
-  Service.recordTelemetryEvent = (object, method, value, extra = undefined) => {
-    recordedEvents.push({ object, method, value, extra });
-  };
-
-  initTestLogging("Trace");
-  Log.repository.getLogger("Sync.Engine.Bookmarks").level = Log.Level.Trace;
-  Log.repository.getLogger("Sync.Engine.Clients").level = Log.Level.Trace;
-  Log.repository.getLogger("Sqlite").level = Log.Level.Info; // less noisy
-});
-
-function checkRecordedEvents(expected, message) {
-  deepEqual(recordedEvents, expected, message);
-  // and clear the list so future checks are easier to write.
-  recordedEvents = [];
-}
-
-// Backs up and resets all preferences to their default values. Returns a
-// function that restores the preferences when called.
-function backupPrefs(names) {
-  let state = new Map();
-  for (let name of names) {
-    state.set(name, Svc.Prefs.get(name));
-    Svc.Prefs.reset(name);
-  }
-  return () => {
-    for (let [name, value] of state) {
-      Svc.Prefs.set(name, value);
-    }
-  };
-}
-
-async function promiseValidationDone(expected) {
-  // wait for a validation to complete.
-  let obs = promiseOneObserver("weave:engine:validate:finish");
-  let { subject: validationResult } = await obs;
-  // check the results - anything non-zero is checked against |expected|
-  let summary = validationResult.problems.getSummary();
-  let actual = summary.filter(({name, count}) => count);
-  actual.sort((a, b) => String(a.name).localeCompare(b.name));
-  expected.sort((a, b) => String(a.name).localeCompare(b.name));
-  deepEqual(actual, expected);
-}
-
-async function cleanup(server) {
-  await bookmarksEngine._store.wipe();
-  await clientsEngine._store.wipe();
-  Svc.Prefs.resetBranch("");
-  Service.recordManager.clearCache();
-  await promiseStopServer(server);
-}
-
-add_task(async function test_bookmark_repair_integration() {
-  enableValidationPrefs();
-
-  _("Ensure that a validation error triggers a repair request.");
-
-  let server = await serverForFoo(bookmarksEngine);
-  await SyncTestingInfrastructure(server);
-
-  let user = server.user("foo");
-
-  let initialID = Service.clientsEngine.localID;
-  let remoteID = Utils.makeGUID();
-  try {
-
-    _("Syncing to initialize crypto etc.");
-    await Service.sync();
-
-    _("Create remote client record");
-    server.insertWBO("foo", "clients", new ServerWBO(remoteID, encryptPayload({
-      id: remoteID,
-      name: "Remote client",
-      type: "desktop",
-      commands: [],
-      version: "54",
-      protocols: ["1.5"],
-    }), Date.now() / 1000));
-
-    _("Create bookmark and folder");
-    let folderInfo = await PlacesUtils.bookmarks.insert({
-      parentGuid: PlacesUtils.bookmarks.toolbarGuid,
-      type: PlacesUtils.bookmarks.TYPE_FOLDER,
-      title: "Folder 1",
-    });
-    let bookmarkInfo = await PlacesUtils.bookmarks.insert({
-      parentGuid: folderInfo.guid,
-      url: "http://getfirefox.com/",
-      title: "Get Firefox!",
-    });
-
-    _(`Upload ${folderInfo.guid} and ${bookmarkInfo.guid} to server`);
-    let validationPromise = promiseValidationDone([]);
-    await Service.sync();
-    equal(clientsEngine.stats.numClients, 2, "Clients collection should have 2 records");
-    await validationPromise;
-    checkRecordedEvents([], "Should not start repair after first sync");
-
-    _("Back up last sync timestamps for remote client");
-    let restoreRemoteLastBookmarkSync = backupPrefs(LAST_BOOKMARK_SYNC_PREFS);
-
-    _(`Delete ${bookmarkInfo.guid} locally and on server`);
-    // Now we will reach into the server and hard-delete the bookmark
-    user.collection("bookmarks").remove(bookmarkInfo.guid);
-    // And delete the bookmark, but cheat by telling places that Sync did
-    // it, so we don't end up with a tombstone.
-    await PlacesUtils.bookmarks.remove(bookmarkInfo.guid, {
-      source: PlacesUtils.bookmarks.SOURCE_SYNC,
-    });
-    deepEqual((await bookmarksEngine.pullNewChanges()), {},
-      `Should not upload tombstone for ${bookmarkInfo.guid}`);
-
-    // sync again - we should have a few problems...
-    _("Sync again to trigger repair");
-    validationPromise = promiseValidationDone([
-      {"name": "missingChildren", "count": 1},
-      {"name": "structuralDifferences", "count": 1},
-    ]);
-    await Service.sync();
-    await validationPromise;
-    let flowID = Svc.Prefs.get("repairs.bookmarks.flowID");
-    checkRecordedEvents([{
-      object: "repair",
-      method: "started",
-      value: undefined,
-      extra: {
-        flowID,
-        numIDs: "2",
-      },
-    }, {
-      object: "sendcommand",
-      method: "repairRequest",
-      value: undefined,
-      extra: {
-        flowID,
-        deviceID: Service.identity.hashedDeviceID(remoteID),
-      },
-    }, {
-      object: "repair",
-      method: "request",
-      value: "upload",
-      extra: {
-        deviceID: Service.identity.hashedDeviceID(remoteID),
-        flowID,
-        numIDs: "2",
-      },
-    }], "Should record telemetry events for repair request");
-
-    // We should have started a repair with our second client.
-    equal((await clientsEngine.getClientCommands(remoteID)).length, 1,
-      "Should queue repair request for remote client after repair");
-    _("Sync to send outgoing repair request");
-    await Service.sync();
-    equal((await clientsEngine.getClientCommands(remoteID)).length, 0,
-      "Should send repair request to remote client after next sync");
-    checkRecordedEvents([],
-      "Should not record repair telemetry after sending repair request");
-
-    _("Back up repair state to restore later");
-    let restoreInitialRepairState = backupPrefs(BOOKMARK_REPAIR_STATE_PREFS);
-
-    // so now let's take over the role of that other client!
-    _("Create new clients engine pretending to be remote client");
-    let remoteClientsEngine = Service.clientsEngine = new ClientEngine(Service);
-    remoteClientsEngine.ignoreLastModifiedOnProcessCommands = true;
-    await remoteClientsEngine.initialize();
-    remoteClientsEngine.localID = remoteID;
-
-    _("Restore missing bookmark");
-    // Pretend Sync wrote the bookmark, so that we upload it as part of the
-    // repair instead of the sync.
-    bookmarkInfo.source = PlacesUtils.bookmarks.SOURCE_SYNC;
-    await PlacesUtils.bookmarks.insert(bookmarkInfo);
-    restoreRemoteLastBookmarkSync();
-
-    _("Sync as remote client");
-    await Service.sync();
-    checkRecordedEvents([{
-      object: "processcommand",
-      method: "repairRequest",
-      value: undefined,
-      extra: {
-        flowID,
-      },
-    }, {
-      object: "repairResponse",
-      method: "uploading",
-      value: undefined,
-      extra: {
-        flowID,
-        numIDs: "2",
-      },
-    }, {
-      object: "sendcommand",
-      method: "repairResponse",
-      value: undefined,
-      extra: {
-        flowID,
-        deviceID: Service.identity.hashedDeviceID(initialID),
-      },
-    }, {
-      object: "repairResponse",
-      method: "finished",
-      value: undefined,
-      extra: {
-        flowID,
-        numIDs: "2",
-      }
-    }], "Should record telemetry events for repair response");
-
-    // We should queue the repair response for the initial client.
-    equal((await remoteClientsEngine.getClientCommands(initialID)).length, 1,
-      "Should queue repair response for initial client after repair");
-    ok(user.collection("bookmarks").wbo(bookmarkInfo.guid),
-      "Should upload missing bookmark");
-
-    _("Sync to upload bookmark and send outgoing repair response");
-    await Service.sync();
-    equal((await remoteClientsEngine.getClientCommands(initialID)).length, 0,
-      "Should send repair response to initial client after next sync");
-    checkRecordedEvents([],
-      "Should not record repair telemetry after sending repair response");
-    ok(!Services.prefs.prefHasUserValue("services.sync.repairs.bookmarks.state"),
-      "Remote client should not be repairing");
-
-    _("Pretend to be initial client again");
-    Service.clientsEngine = clientsEngine;
-
-    _("Restore incomplete Places database and prefs");
-    await PlacesUtils.bookmarks.remove(bookmarkInfo.guid, {
-      source: PlacesUtils.bookmarks.SOURCE_SYNC,
-    });
-    restoreInitialRepairState();
-    ok(Services.prefs.prefHasUserValue("services.sync.repairs.bookmarks.state"),
-      "Initial client should still be repairing");
-
-    _("Sync as initial client");
-    let revalidationPromise = promiseValidationDone([]);
-    await Service.sync();
-    let restoredBookmarkInfo = await PlacesUtils.bookmarks.fetch(bookmarkInfo.guid);
-    ok(restoredBookmarkInfo, "Missing bookmark should be downloaded to initial client");
-    checkRecordedEvents([{
-      object: "processcommand",
-      method: "repairResponse",
-      value: undefined,
-      extra: {
-        flowID,
-      },
-    }, {
-      object: "repair",
-      method: "response",
-      value: "upload",
-      extra: {
-        flowID,
-        deviceID: Service.identity.hashedDeviceID(remoteID),
-        numIDs: "2",
-      },
-    }, {
-      object: "repair",
-      method: "finished",
-      value: undefined,
-      extra: {
-        flowID,
-        numIDs: "0",
-      },
-    }]);
-    await revalidationPromise;
-    ok(!Services.prefs.prefHasUserValue("services.sync.repairs.bookmarks.state"),
-      "Should clear repair pref after successfully completing repair");
-  } finally {
-    await cleanup(server);
-    clientsEngine = Service.clientsEngine = new ClientEngine(Service);
-    clientsEngine.ignoreLastModifiedOnProcessCommands = true;
-    clientsEngine.initialize();
-  }
-});
-
-add_task(async function test_repair_client_missing() {
-  enableValidationPrefs();
-
-  _("Ensure that a record missing from the client only will get re-downloaded from the server");
-
-  let server = await serverForFoo(bookmarksEngine);
-  await SyncTestingInfrastructure(server);
-
-  let remoteID = Utils.makeGUID();
-  try {
-
-    _("Syncing to initialize crypto etc.");
-    await Service.sync();
-
-    _("Create remote client record");
-    server.insertWBO("foo", "clients", new ServerWBO(remoteID, encryptPayload({
-      id: remoteID,
-      name: "Remote client",
-      type: "desktop",
-      commands: [],
-      version: "54",
-      protocols: ["1.5"],
-    }), Date.now() / 1000));
-
-    let bookmarkInfo = await PlacesUtils.bookmarks.insert({
-      parentGuid: PlacesUtils.bookmarks.toolbarGuid,
-      url: "http://getfirefox.com/",
-      title: "Get Firefox!",
-    });
-
-    let validationPromise = promiseValidationDone([]);
-    _("Syncing.");
-    await Service.sync();
-    // should have 2 clients
-    equal(clientsEngine.stats.numClients, 2);
-    await validationPromise;
-
-    // Delete the bookmark localy, but cheat by telling places that Sync did
-    // it, so Sync still thinks we have it.
-    await PlacesUtils.bookmarks.remove(bookmarkInfo.guid, {
-      source: PlacesUtils.bookmarks.SOURCE_SYNC,
-    });
-    // sanity check we aren't going to sync this removal.
-    do_check_empty((await bookmarksEngine.pullNewChanges()));
-    // sanity check that the bookmark is not there anymore
-    do_check_false(await PlacesUtils.bookmarks.fetch(bookmarkInfo.guid));
-
-    // sync again - we should have a few problems...
-    _("Syncing again.");
-    validationPromise = promiseValidationDone([
-      {"name": "clientMissing", "count": 1},
-      {"name": "structuralDifferences", "count": 1},
-    ]);
-    await Service.sync();
-    await validationPromise;
-
-    // We shouldn't have started a repair with our second client.
-    equal((await clientsEngine.getClientCommands(remoteID)).length, 0);
-
-    // Trigger a sync (will request the missing item)
-    await Service.sync();
-
-    // And we got our bookmark back
-    do_check_true(await PlacesUtils.bookmarks.fetch(bookmarkInfo.guid));
-  } finally {
-    await cleanup(server);
-  }
-});
-
-add_task(async function test_repair_server_missing() {
-  enableValidationPrefs();
-
-  _("Ensure that a record missing from the server only will get re-upload from the client");
-
-  let server = await serverForFoo(bookmarksEngine);
-  await SyncTestingInfrastructure(server);
-
-  let user = server.user("foo");
-
-  let remoteID = Utils.makeGUID();
-  try {
-
-    _("Syncing to initialize crypto etc.");
-    await Service.sync();
-
-    _("Create remote client record");
-    server.insertWBO("foo", "clients", new ServerWBO(remoteID, encryptPayload({
-      id: remoteID,
-      name: "Remote client",
-      type: "desktop",
-      commands: [],
-      version: "54",
-      protocols: ["1.5"],
-    }), Date.now() / 1000));
-
-    let bookmarkInfo = await PlacesUtils.bookmarks.insert({
-      parentGuid: PlacesUtils.bookmarks.toolbarGuid,
-      url: "http://getfirefox.com/",
-      title: "Get Firefox!",
-    });
-
-    let validationPromise = promiseValidationDone([]);
-    _("Syncing.");
-    await Service.sync();
-    // should have 2 clients
-    equal(clientsEngine.stats.numClients, 2);
-    await validationPromise;
-
-    // Now we will reach into the server and hard-delete the bookmark
-    user.collection("bookmarks").wbo(bookmarkInfo.guid).delete();
-
-    // sync again - we should have a few problems...
-    _("Syncing again.");
-    validationPromise = promiseValidationDone([
-      {"name": "serverMissing", "count": 1},
-      {"name": "missingChildren", "count": 1},
-    ]);
-    await Service.sync();
-    await validationPromise;
-
-    // We shouldn't have started a repair with our second client.
-    equal((await clientsEngine.getClientCommands(remoteID)).length, 0);
-
-    // Trigger a sync (will upload the missing item)
-    await Service.sync();
-
-    // And the server got our bookmark back
-    do_check_true(user.collection("bookmarks").wbo(bookmarkInfo.guid));
-  } finally {
-    await cleanup(server);
-  }
-});
-
-add_task(async function test_repair_server_deleted() {
-  enableValidationPrefs();
-
-  _("Ensure that a record marked as deleted on the server but present on the client will get deleted on the client");
-
-  let server = await serverForFoo(bookmarksEngine);
-  await SyncTestingInfrastructure(server);
-
-  let remoteID = Utils.makeGUID();
-  try {
-
-    _("Syncing to initialize crypto etc.");
-    await Service.sync();
-
-    _("Create remote client record");
-    server.insertWBO("foo", "clients", new ServerWBO(remoteID, encryptPayload({
-      id: remoteID,
-      name: "Remote client",
-      type: "desktop",
-      commands: [],
-      version: "54",
-      protocols: ["1.5"],
-    }), Date.now() / 1000));
-
-    let bookmarkInfo = await PlacesUtils.bookmarks.insert({
-      parentGuid: PlacesUtils.bookmarks.toolbarGuid,
-      url: "http://getfirefox.com/",
-      title: "Get Firefox!",
-    });
-
-    let validationPromise = promiseValidationDone([]);
-    _("Syncing.");
-    await Service.sync();
-    // should have 2 clients
-    equal(clientsEngine.stats.numClients, 2);
-    await validationPromise;
-
-    // Now we will reach into the server and create a tombstone for that bookmark
-    // but with a last-modified in the past - this way our sync isn't going to
-    // pick up the record.
-    server.insertWBO("foo", "bookmarks", new ServerWBO(bookmarkInfo.guid, encryptPayload({
-      id: bookmarkInfo.guid,
-      deleted: true,
-    }), (Date.now() - 60000) / 1000));
-
-    // sync again - we should have a few problems...
-    _("Syncing again.");
-    validationPromise = promiseValidationDone([
-      {"name": "serverDeleted", "count": 1},
-      {"name": "deletedChildren", "count": 1},
-      {"name": "orphans", "count": 1}
-    ]);
-    await Service.sync();
-    await validationPromise;
-
-    // We shouldn't have started a repair with our second client.
-    equal((await clientsEngine.getClientCommands(remoteID)).length, 0);
-
-    // Trigger a sync (will upload the missing item)
-    await Service.sync();
-
-    // And the client deleted our bookmark
-    do_check_true(!(await PlacesUtils.bookmarks.fetch(bookmarkInfo.guid)));
-  } finally {
-    await cleanup(server);
-  }
-});
deleted file mode 100644
--- a/services/sync/tests/unit/test_bookmark_repair_requestor.js
+++ /dev/null
@@ -1,514 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-Cu.import("resource://services-sync/bookmark_repair.js");
-
-initTestLogging("Trace");
-
-function makeClientRecord(id, fields = {}) {
-  return {
-    id,
-    version: fields.version || "54.0a1",
-    type: fields.type || "desktop",
-    stale: fields.stale || false,
-    serverLastModified: fields.serverLastModified || 0,
-  };
-}
-
-class MockClientsEngine {
-  constructor(clientList) {
-    this._clientList = clientList;
-    this._sentCommands = {};
-  }
-
-  get remoteClients() {
-    return Object.values(this._clientList);
-  }
-
-  remoteClient(id) {
-    return this._clientList[id];
-  }
-
-  async sendCommand(command, args, clientID) {
-    let cc = this._sentCommands[clientID] || [];
-    cc.push({ command, args });
-    this._sentCommands[clientID] = cc;
-  }
-
-  async getClientCommands(clientID) {
-    return this._sentCommands[clientID] || [];
-  }
-}
-
-class MockIdentity {
-  hashedDeviceID(did) {
-    return did; // don't hash it to make testing easier.
-  }
-}
-
-class MockService {
-  constructor(clientList) {
-    this.clientsEngine = new MockClientsEngine(clientList);
-    this.identity = new MockIdentity();
-    this._recordedEvents = [];
-  }
-
-  recordTelemetryEvent(object, method, value, extra = undefined) {
-    this._recordedEvents.push({ method, object, value, extra });
-  }
-}
-
-function checkState(expected) {
-  equal(Services.prefs.getCharPref("services.sync.repairs.bookmarks.state"), expected);
-}
-
-function checkRepairFinished() {
-  try {
-    let state = Services.prefs.getCharPref("services.sync.repairs.bookmarks.state");
-    ok(false, state);
-  } catch (ex) {
-    ok(true, "no repair preference exists");
-  }
-}
-
-function checkOutgoingCommand(service, clientID) {
-  let sent = service.clientsEngine._sentCommands;
-  deepEqual(Object.keys(sent), [clientID]);
-  equal(sent[clientID].length, 1);
-  equal(sent[clientID][0].command, "repairRequest");
-}
-
-function NewBookmarkRepairRequestor(mockService) {
-  let req = new BookmarkRepairRequestor(mockService);
-  req._now = () => Date.now() / 1000; // _now() is seconds.
-  return req;
-}
-
-add_task(async function test_requestor_no_clients() {
-  let mockService = new MockService({ });
-  let requestor = NewBookmarkRepairRequestor(mockService);
-  let validationInfo = {
-    problems: {
-      missingChildren: [
-        {parent: "x", child: "a"},
-        {parent: "x", child: "b"},
-        {parent: "x", child: "c"}
-      ],
-      orphans: [],
-    }
-  };
-  let flowID = Utils.makeGUID();
-
-  await requestor.startRepairs(validationInfo, flowID);
-  // there are no clients, so we should end up in "finished" (which we need to
-  // check via telemetry)
-  deepEqual(mockService._recordedEvents, [
-    { object: "repair",
-      method: "started",
-      value: undefined,
-      extra: { flowID, numIDs: 4 },
-    },
-    { object: "repair",
-      method: "finished",
-      value: undefined,
-      extra: { flowID, numIDs: 4 },
-    }
-  ]);
-});
-
-add_task(async function test_requestor_one_client_no_response() {
-  let mockService = new MockService({ "client-a": makeClientRecord("client-a") });
-  let requestor = NewBookmarkRepairRequestor(mockService);
-  let validationInfo = {
-    problems: {
-      missingChildren: [
-        {parent: "x", child: "a"},
-        {parent: "x", child: "b"},
-        {parent: "x", child: "c"}
-      ],
-      orphans: [],
-    }
-  };
-  let flowID = Utils.makeGUID();
-  await requestor.startRepairs(validationInfo, flowID);
-  // the command should now be outgoing.
-  checkOutgoingCommand(mockService, "client-a");
-
-  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
-  // asking it to continue stays in that state until we timeout or the command
-  // is removed.
-  await requestor.continueRepairs();
-  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
-
-  // now pretend that client synced.
-  mockService.clientsEngine._sentCommands = {};
-  await requestor.continueRepairs();
-  checkState(BookmarkRepairRequestor.STATE.SENT_SECOND_REQUEST);
-  // the command should be outgoing again.
-  checkOutgoingCommand(mockService, "client-a");
-
-  // pretend that client synced again without writing a command.
-  mockService.clientsEngine._sentCommands = {};
-  await requestor.continueRepairs();
-  // There are no more clients, so we've given up.
-
-  checkRepairFinished();
-  deepEqual(mockService._recordedEvents, [
-    { object: "repair",
-      method: "started",
-      value: undefined,
-      extra: { flowID, numIDs: 4 },
-    },
-    { object: "repair",
-      method: "request",
-      value: "upload",
-      extra: { flowID, numIDs: 4, deviceID: "client-a" },
-    },
-    { object: "repair",
-      method: "request",
-      value: "upload",
-      extra: { flowID, numIDs: 4, deviceID: "client-a" },
-    },
-    { object: "repair",
-      method: "finished",
-      value: undefined,
-      extra: { flowID, numIDs: 4 },
-    }
-  ]);
-});
-
-add_task(async function test_requestor_one_client_no_sync() {
-  let mockService = new MockService({ "client-a": makeClientRecord("client-a") });
-  let requestor = NewBookmarkRepairRequestor(mockService);
-  let validationInfo = {
-    problems: {
-      missingChildren: [
-        {parent: "x", child: "a"},
-        {parent: "x", child: "b"},
-        {parent: "x", child: "c"}
-      ],
-      orphans: [],
-    }
-  };
-  let flowID = Utils.makeGUID();
-  await requestor.startRepairs(validationInfo, flowID);
-  // the command should now be outgoing.
-  checkOutgoingCommand(mockService, "client-a");
-
-  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
-
-  // pretend we are now in the future.
-  let theFuture = Date.now() + 300000000;
-  requestor._now = () => theFuture;
-
-  await requestor.continueRepairs();
-
-  // We should be finished as we gave up in disgust.
-  checkRepairFinished();
-  deepEqual(mockService._recordedEvents, [
-    { object: "repair",
-      method: "started",
-      value: undefined,
-      extra: { flowID, numIDs: 4 },
-    },
-    { object: "repair",
-      method: "request",
-      value: "upload",
-      extra: { flowID, numIDs: 4, deviceID: "client-a" },
-    },
-    { object: "repair",
-      method: "abandon",
-      value: "silent",
-      extra: { flowID, deviceID: "client-a" },
-    },
-    { object: "repair",
-      method: "finished",
-      value: undefined,
-      extra: { flowID, numIDs: 4 },
-    }
-  ]);
-});
-
-add_task(async function test_requestor_latest_client_used() {
-  let mockService = new MockService({
-    "client-early": makeClientRecord("client-early", { serverLastModified: Date.now() - 10 }),
-    "client-late": makeClientRecord("client-late", { serverLastModified: Date.now() }),
-  });
-  let requestor = NewBookmarkRepairRequestor(mockService);
-  let validationInfo = {
-    problems: {
-      missingChildren: [
-        { parent: "x", child: "a" },
-      ],
-      orphans: [],
-    }
-  };
-  await requestor.startRepairs(validationInfo, Utils.makeGUID());
-  // the repair command should be outgoing to the most-recent client.
-  checkOutgoingCommand(mockService, "client-late");
-  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
-  // and this test is done - reset the repair.
-  requestor.prefs.resetBranch();
-});
-
-add_task(async function test_requestor_client_vanishes() {
-  let mockService = new MockService({
-    "client-a": makeClientRecord("client-a"),
-    "client-b": makeClientRecord("client-b"),
-  });
-  let requestor = NewBookmarkRepairRequestor(mockService);
-  let validationInfo = {
-    problems: {
-      missingChildren: [
-        {parent: "x", child: "a"},
-        {parent: "x", child: "b"},
-        {parent: "x", child: "c"}
-      ],
-      orphans: [],
-    }
-  };
-  let flowID = Utils.makeGUID();
-  await requestor.startRepairs(validationInfo, flowID);
-  // the command should now be outgoing.
-  checkOutgoingCommand(mockService, "client-a");
-
-  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
-
-  mockService.clientsEngine._sentCommands = {};
-  // Now let's pretend the client vanished.
-  delete mockService.clientsEngine._clientList["client-a"];
-
-  await requestor.continueRepairs();
-  // We should have moved on to client-b.
-  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
-  checkOutgoingCommand(mockService, "client-b");
-
-  // Now let's pretend client B wrote all missing IDs.
-  let response = {
-    collection: "bookmarks",
-    request: "upload",
-    flowID: requestor._flowID,
-    clientID: "client-b",
-    ids: ["a", "b", "c", "x"],
-  };
-  await requestor.continueRepairs(response);
-
-  // We should be finished as we got all our IDs.
-  checkRepairFinished();
-  deepEqual(mockService._recordedEvents, [
-    { object: "repair",
-      method: "started",
-      value: undefined,
-      extra: { flowID, numIDs: 4 },
-    },
-    { object: "repair",
-      method: "request",
-      value: "upload",
-      extra: { flowID, numIDs: 4, deviceID: "client-a" },
-    },
-    { object: "repair",
-      method: "abandon",
-      value: "missing",
-      extra: { flowID, deviceID: "client-a" },
-    },
-    { object: "repair",
-      method: "request",
-      value: "upload",
-      extra: { flowID, numIDs: 4, deviceID: "client-b" },
-    },
-    { object: "repair",
-      method: "response",
-      value: "upload",
-      extra: { flowID, deviceID: "client-b", numIDs: 4 },
-    },
-    { object: "repair",
-      method: "finished",
-      value: undefined,
-      extra: { flowID, numIDs: 0 },
-    }
-  ]);
-});
-
-add_task(async function test_requestor_success_responses() {
-  let mockService = new MockService({
-    "client-a": makeClientRecord("client-a"),
-    "client-b": makeClientRecord("client-b"),
-  });
-  let requestor = NewBookmarkRepairRequestor(mockService);
-  let validationInfo = {
-    problems: {
-      missingChildren: [
-        {parent: "x", child: "a"},
-        {parent: "x", child: "b"},
-        {parent: "x", child: "c"}
-      ],
-      orphans: [],
-    }
-  };
-  let flowID = Utils.makeGUID();
-  await requestor.startRepairs(validationInfo, flowID);
-  // the command should now be outgoing.
-  checkOutgoingCommand(mockService, "client-a");
-
-  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
-
-  mockService.clientsEngine._sentCommands = {};
-  // Now let's pretend the client wrote a response.
-  let response = {
-    collection: "bookmarks",
-    request: "upload",
-    clientID: "client-a",
-    flowID: requestor._flowID,
-    ids: ["a", "b"],
-  };
-  await requestor.continueRepairs(response);
-  // We should have moved on to client 2.
-  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
-  checkOutgoingCommand(mockService, "client-b");
-
-  // Now let's pretend client B write the missing ID.
-  response = {
-    collection: "bookmarks",
-    request: "upload",
-    clientID: "client-b",
-    flowID: requestor._flowID,
-    ids: ["c", "x"],
-  };
-  await requestor.continueRepairs(response);
-
-  // We should be finished as we got all our IDs.
-  checkRepairFinished();
-  deepEqual(mockService._recordedEvents, [
-    { object: "repair",
-      method: "started",
-      value: undefined,
-      extra: { flowID, numIDs: 4 },
-    },
-    { object: "repair",
-      method: "request",
-      value: "upload",
-      extra: { flowID, numIDs: 4, deviceID: "client-a" },
-    },
-    { object: "repair",
-      method: "response",
-      value: "upload",
-      extra: { flowID, deviceID: "client-a", numIDs: 2 },
-    },
-    { object: "repair",
-      method: "request",
-      value: "upload",
-      extra: { flowID, numIDs: 2, deviceID: "client-b" },
-    },
-    { object: "repair",
-      method: "response",
-      value: "upload",
-      extra: { flowID, deviceID: "client-b", numIDs: 2 },
-    },
-    { object: "repair",
-      method: "finished",
-      value: undefined,
-      extra: { flowID, numIDs: 0 },
-    }
-  ]);
-});
-
-add_task(async function test_client_suitability() {
-  let mockService = new MockService({
-    "client-a": makeClientRecord("client-a"),
-    "client-b": makeClientRecord("client-b", { type: "mobile" }),
-    "client-c": makeClientRecord("client-c", { version: "52.0a1" }),
-    "client-d": makeClientRecord("client-c", { version: "54.0a1" }),
-  });
-  let requestor = NewBookmarkRepairRequestor(mockService);
-  ok(requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-a")));
-  ok(!requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-b")));
-  ok(!requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-c")));
-  ok(requestor._isSuitableClient(mockService.clientsEngine.remoteClient("client-d")));
-});
-
-add_task(async function test_requestor_already_repairing_at_start() {
-  let mockService = new MockService({ });
-  let requestor = NewBookmarkRepairRequestor(mockService);
-  requestor.anyClientsRepairing = () => true;
-  let validationInfo = {
-    problems: {
-      missingChildren: [
-        {parent: "x", child: "a"},
-        {parent: "x", child: "b"},
-        {parent: "x", child: "c"}
-      ],
-      orphans: [],
-    }
-  };
-  let flowID = Utils.makeGUID();
-
-  ok(!(await requestor.startRepairs(validationInfo, flowID)),
-     "Shouldn't start repairs");
-  equal(mockService._recordedEvents.length, 1);
-  equal(mockService._recordedEvents[0].method, "aborted");
-});
-
-add_task(async function test_requestor_already_repairing_continue() {
-  let clientB = makeClientRecord("client-b");
-  let mockService = new MockService({
-    "client-a": makeClientRecord("client-a"),
-    "client-b": clientB
-  });
-  let requestor = NewBookmarkRepairRequestor(mockService);
-  let validationInfo = {
-    problems: {
-      missingChildren: [
-        {parent: "x", child: "a"},
-        {parent: "x", child: "b"},
-        {parent: "x", child: "c"}
-      ],
-      orphans: [],
-    }
-  };
-  let flowID = Utils.makeGUID();
-  await requestor.startRepairs(validationInfo, flowID);
-  // the command should now be outgoing.
-  checkOutgoingCommand(mockService, "client-a");
-
-  checkState(BookmarkRepairRequestor.STATE.SENT_REQUEST);
-  mockService.clientsEngine._sentCommands = {};
-
-  // Now let's pretend the client wrote a response (it doesn't matter what's in here)
-  let response = {
-    collection: "bookmarks",
-    request: "upload",
-    clientID: "client-a",
-    flowID: requestor._flowID,
-    ids: ["a", "b"],
-  };
-
-  // and another client also started a request
-  clientB.commands = [{
-    args: [{ collection: "bookmarks", flowID: "asdf" }],
-    command: "repairRequest",
-  }];
-
-
-  await requestor.continueRepairs(response);
-
-  // We should have aborted now
-  checkRepairFinished();
-  const expected = [
-    { method: "started",
-      object: "repair",
-      value: undefined,
-      extra: { flowID, numIDs: "4" },
-    },
-    { method: "request",
-      object: "repair",
-      value: "upload",
-      extra: { flowID, numIDs: "4", deviceID: "client-a" },
-    },
-    { method: "aborted",
-      object: "repair",
-      value: undefined,
-      extra: { flowID, numIDs: "4", reason: "other clients repairing" },
-    }
-  ];
-
-  deepEqual(mockService._recordedEvents, expected);
-});
deleted file mode 100644
--- a/services/sync/tests/unit/test_bookmark_repair_responder.js
+++ /dev/null
@@ -1,611 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-Cu.import("resource:///modules/PlacesUIUtils.jsm");
-Cu.import("resource://gre/modules/Log.jsm");
-
-Cu.import("resource://services-sync/engines/bookmarks.js");
-Cu.import("resource://services-sync/service.js");
-Cu.import("resource://services-sync/bookmark_repair.js");
-Cu.import("resource://testing-common/services/sync/utils.js");
-
-initTestLogging("Trace");
-Log.repository.getLogger("Sync.Engine.Bookmarks").level = Log.Level.Trace;
-
-// Disable validation so that we don't try to automatically repair the server
-// when we sync.
-Svc.Prefs.set("engine.bookmarks.validation.enabled", false);
-
-// stub telemetry so we can easily check the right things are recorded.
-var recordedEvents = [];
-
-function checkRecordedEvents(expected) {
-  deepEqual(recordedEvents, expected);
-  // and clear the list so future checks are easier to write.
-  recordedEvents = [];
-}
-
-function getServerBookmarks(server) {
-  return server.user("foo").collection("bookmarks");
-}
-
-async function makeServer() {
-  let server = await serverForFoo(bookmarksEngine);
-  await SyncTestingInfrastructure(server);
-  return server;
-}
-
-async function cleanup(server) {
-  await promiseStopServer(server);
-  await PlacesSyncUtils.bookmarks.wipe();
-  // clear keys so when each test finds a different server it accepts its keys.
-  Service.collectionKeys.clear();
-}
-
-let bookmarksEngine;
-
-add_task(async function setup() {
-  bookmarksEngine = Service.engineManager.get("bookmarks");
-
-  Service.recordTelemetryEvent = (object, method, value, extra = undefined) => {
-    recordedEvents.push({ object, method, value, extra });
-  };
-});
-
-add_task(async function test_responder_error() {
-  let server = await makeServer();
-
-  // sync so the collection is created.
-  await Service.sync();
-
-  let request = {
-    request: "upload",
-    ids: [Utils.makeGUID()],
-    flowID: Utils.makeGUID(),
-  };
-  let responder = new BookmarkRepairResponder();
-  // mock the responder to simulate an error.
-  responder._fetchItemsToUpload = async function() {
-    throw new Error("oh no!");
-  };
-  await responder.repair(request, null);
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "failed",
-      value: undefined,
-      extra: { flowID: request.flowID,
-               numIDs: "0",
-               failureReason: '{"name":"unexpectederror","error":"Error: oh no!"}',
-      }
-    },
-  ]);
-
-  await cleanup(server);
-});
-
-add_task(async function test_responder_no_items() {
-  let server = await makeServer();
-
-  // sync so the collection is created.
-  await Service.sync();
-
-  let request = {
-    request: "upload",
-    ids: [Utils.makeGUID()],
-    flowID: Utils.makeGUID(),
-  };
-  let responder = new BookmarkRepairResponder();
-  await responder.repair(request, null);
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "finished",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "0"},
-    },
-  ]);
-
-  await cleanup(server);
-});
-
-// One item requested and we have it locally, but it's not yet on the server.
-add_task(async function test_responder_upload() {
-  let server = await makeServer();
-
-  // Pretend we've already synced this bookmark, so that we can ensure it's
-  // uploaded in response to our repair request.
-  let bm = await PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
-                                                title: "Get Firefox",
-                                                url: "http://getfirefox.com/",
-                                                source: PlacesUtils.bookmarks.SOURCES.SYNC });
-
-  await Service.sync();
-  deepEqual(getServerBookmarks(server).keys().sort(), [
-    "menu",
-    "mobile",
-    "toolbar",
-    "unfiled",
-  ], "Should only upload roots on first sync");
-
-  let request = {
-    request: "upload",
-    ids: [bm.guid],
-    flowID: Utils.makeGUID(),
-  };
-  let responder = new BookmarkRepairResponder();
-  await responder.repair(request, null);
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "uploading",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "1"},
-    },
-  ]);
-
-  await Service.sync();
-  deepEqual(getServerBookmarks(server).keys().sort(), [
-    "menu",
-    "mobile",
-    "toolbar",
-    "unfiled",
-    bm.guid,
-  ].sort(), "Should upload requested bookmark on second sync");
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "finished",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "1"},
-    },
-  ]);
-
-  await cleanup(server);
-});
-
-// One item requested and we have it locally and it's already on the server.
-// As it was explicitly requested, we should upload it.
-add_task(async function test_responder_item_exists_locally() {
-  let server = await makeServer();
-
-  let bm = await PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
-                                                title: "Get Firefox",
-                                                url: "http://getfirefox.com/" });
-  // first sync to get the item on the server.
-  _("Syncing to get item on the server");
-  await Service.sync();
-
-  // issue a repair request for it.
-  let request = {
-    request: "upload",
-    ids: [bm.guid],
-    flowID: Utils.makeGUID(),
-  };
-  let responder = new BookmarkRepairResponder();
-  await responder.repair(request, null);
-
-  // We still re-upload the item.
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "uploading",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "1"},
-    },
-  ]);
-
-  _("Syncing to do the upload.");
-  await Service.sync();
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "finished",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "1"},
-    },
-  ]);
-  await cleanup(server);
-});
-
-add_task(async function test_responder_tombstone() {
-  let server = await makeServer();
-
-  // TODO: Request an item for which we have a tombstone locally. Decide if
-  // we want to store tombstones permanently for this. In the integration
-  // test, we can also try requesting a deleted child or ancestor.
-
-  // For now, we'll handle this identically to `test_responder_missing_items`.
-  // Bug 1343103 is a follow-up to better handle this.
-  await cleanup(server);
-});
-
-add_task(async function test_responder_missing_items() {
-  let server = await makeServer();
-
-  let fxBmk = await PlacesUtils.bookmarks.insert({
-    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
-    title: "Get Firefox",
-    url: "http://getfirefox.com/",
-  });
-  let tbBmk = await PlacesUtils.bookmarks.insert({
-    parentGuid: PlacesUtils.bookmarks.unfiledGuid,
-    title: "Get Thunderbird",
-    url: "http://getthunderbird.com/",
-    // Pretend we've already synced Thunderbird.
-    source: PlacesUtils.bookmarks.SOURCES.SYNC,
-  });
-
-  await Service.sync();
-  deepEqual(getServerBookmarks(server).keys().sort(), [
-    "menu",
-    "mobile",
-    "toolbar",
-    "unfiled",
-    fxBmk.guid,
-  ].sort(), "Should upload roots and Firefox on first sync");
-
-  _("Request Firefox, Thunderbird, and nonexistent GUID");
-  let request = {
-    request: "upload",
-    ids: [fxBmk.guid, tbBmk.guid, Utils.makeGUID()],
-    flowID: Utils.makeGUID(),
-  };
-  let responder = new BookmarkRepairResponder();
-  await responder.repair(request, null);
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "uploading",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "2"},
-    },
-  ]);
-
-  _("Sync after requesting IDs");
-  await Service.sync();
-  deepEqual(getServerBookmarks(server).keys().sort(), [
-    "menu",
-    "mobile",
-    "toolbar",
-    "unfiled",
-    fxBmk.guid,
-    tbBmk.guid,
-  ].sort(), "Second sync should upload Thunderbird; skip nonexistent");
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "finished",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "2"},
-    },
-  ]);
-
-  await cleanup(server);
-});
-
-add_task(async function test_non_syncable() {
-  let server = await makeServer();
-
-  await Service.sync(); // to create the collections on the server.
-
-  // Creates the left pane queries as a side effect.
-  let leftPaneId = PlacesUIUtils.leftPaneFolderId;
-  _(`Left pane root ID: ${leftPaneId}`);
-  await PlacesTestUtils.promiseAsyncUpdates();
-
-  // A child folder of the left pane root, containing queries for the menu,
-  // toolbar, and unfiled queries.
-  let allBookmarksId = PlacesUIUtils.leftPaneQueries.AllBookmarks;
-  let allBookmarksGuid = await PlacesUtils.promiseItemGuid(allBookmarksId);
-
-  let unfiledQueryId = PlacesUIUtils.leftPaneQueries.UnfiledBookmarks;
-  let unfiledQueryGuid = await PlacesUtils.promiseItemGuid(unfiledQueryId);
-
-  // Put the "Bookmarks Menu" on the server to simulate old bugs.
-  let bookmarksMenuQueryId = PlacesUIUtils.leftPaneQueries.BookmarksMenu;
-  let bookmarksMenuQueryGuid = await PlacesUtils.promiseItemGuid(bookmarksMenuQueryId);
-  let collection = getServerBookmarks(server);
-  collection.insert(bookmarksMenuQueryGuid, "doesn't matter");
-
-  // Explicitly request the unfiled and allBookmarksGuid queries; these will
-  // get tombstones. Because the BookmarksMenu is already on the server it
-  // should be removed even though it wasn't requested. We should ignore the
-  // toolbar query as it wasn't explicitly requested and isn't on the server.
-  let request = {
-    request: "upload",
-    ids: [allBookmarksGuid, unfiledQueryGuid],
-    flowID: Utils.makeGUID(),
-  };
-  let responder = new BookmarkRepairResponder();
-  await responder.repair(request, null);
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "uploading",
-      value: undefined,
-      // Tombstones for the 2 items we requested and for bookmarksMenu
-      extra: {flowID: request.flowID, numIDs: "3"},
-    },
-  ]);
-
-  _("Sync to upload tombstones for items");
-  await Service.sync();
-
-  let toolbarQueryId = PlacesUIUtils.leftPaneQueries.BookmarksToolbar;
-  let menuQueryId = PlacesUIUtils.leftPaneQueries.BookmarksMenu;
-  let queryGuids = [
-    allBookmarksGuid,
-    await PlacesUtils.promiseItemGuid(toolbarQueryId),
-    await PlacesUtils.promiseItemGuid(menuQueryId),
-    unfiledQueryGuid,
-  ];
-
-  deepEqual(collection.keys().sort(), [
-    // We always upload roots on the first sync.
-    "menu",
-    "mobile",
-    "toolbar",
-    "unfiled",
-    ...request.ids,
-    bookmarksMenuQueryGuid,
-  ].sort(), "Should upload roots and queries on first sync");
-
-  for (let guid of queryGuids) {
-    let wbo = collection.wbo(guid);
-    if (request.ids.indexOf(guid) >= 0 || guid == bookmarksMenuQueryGuid) {
-      // explicitly requested or already on the server, so should have a tombstone.
-      let payload = JSON.parse(JSON.parse(wbo.payload).ciphertext);
-      ok(payload.deleted, `Should upload tombstone for left pane query ${guid}`);
-    } else {
-      // not explicitly requested and not on the server at the start, so should
-      // not be on the server now.
-      ok(!wbo, `Should not upload anything for left pane query ${guid}`);
-    }
-  }
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "finished",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "3"},
-    },
-  ]);
-
-  await cleanup(server);
-});
-
-add_task(async function test_folder_descendants() {
-  let server = await makeServer();
-
-  let parentFolder = await PlacesUtils.bookmarks.insert({
-    type: PlacesUtils.bookmarks.TYPE_FOLDER,
-    parentGuid: PlacesUtils.bookmarks.menuGuid,
-    title: "Parent folder",
-  });
-  let childFolder = await PlacesUtils.bookmarks.insert({
-    type: PlacesUtils.bookmarks.TYPE_FOLDER,
-    parentGuid: parentFolder.guid,
-    title: "Child folder",
-  });
-  // This item is in parentFolder and *should not* be uploaded as part of
-  // the repair even though we explicitly request its parent.
-  let existingChildBmk = await PlacesUtils.bookmarks.insert({
-    parentGuid: parentFolder.guid,
-    title: "Get Firefox",
-    url: "http://firefox.com",
-  });
-  // This item is in parentFolder and *should* be uploaded as part of
-  // the repair because we explicitly request its ID.
-  let childSiblingBmk = await PlacesUtils.bookmarks.insert({
-    parentGuid: parentFolder.guid,
-    title: "Get Thunderbird",
-    url: "http://getthunderbird.com",
-  });
-
-  _("Initial sync to upload roots and parent folder");
-  await Service.sync();
-
-  let initialRecordIds = [
-    "menu",
-    "mobile",
-    "toolbar",
-    "unfiled",
-    parentFolder.guid,
-    existingChildBmk.guid,
-    childFolder.guid,
-    childSiblingBmk.guid,
-  ].sort();
-  deepEqual(getServerBookmarks(server).keys().sort(), initialRecordIds,
-    "Should upload roots and partial folder contents on first sync");
-
-  _("Insert missing bookmarks locally to request later");
-  // Note that the fact we insert the bookmarks via PlacesSyncUtils.bookmarks.insert
-  // means that we are pretending Sync itself wrote them, hence they aren't
-  // considered "changed" locally so never get uploaded.
-  let childBmk = await PlacesSyncUtils.bookmarks.insert({
-    kind: "bookmark",
-    recordId: Utils.makeGUID(),
-    parentRecordId: parentFolder.guid,
-    title: "Get Firefox",
-    url: "http://getfirefox.com",
-  });
-  let grandChildBmk = await PlacesSyncUtils.bookmarks.insert({
-    kind: "bookmark",
-    recordId: Utils.makeGUID(),
-    parentRecordId: childFolder.guid,
-    title: "Bugzilla",
-    url: "https://bugzilla.mozilla.org",
-  });
-  let grandChildSiblingBmk = await PlacesSyncUtils.bookmarks.insert({
-    kind: "bookmark",
-    recordId: Utils.makeGUID(),
-    parentRecordId: childFolder.guid,
-    title: "Mozilla",
-    url: "https://mozilla.org",
-  });
-
-  _("Sync again; server contents shouldn't change");
-  await Service.sync();
-  deepEqual(getServerBookmarks(server).keys().sort(), initialRecordIds,
-    "Second sync should not upload missing bookmarks");
-
-  // This assumes the parent record on the server is correct, and the server
-  // is just missing the children. This isn't a correct assumption if the
-  // parent's `children` array is wrong, or if the parent and children disagree.
-  _("Request missing bookmarks");
-  let request = {
-    request: "upload",
-    ids: [
-      // Already on server (but still uploaded as they are explicitly requested)
-      parentFolder.guid,
-      childSiblingBmk.guid,
-      // Explicitly upload these. We should also upload `grandChildBmk`,
-      // since it's a descendant of `parentFolder` and we requested its
-      // ancestor.
-      childBmk.recordId,
-      grandChildSiblingBmk.recordId],
-    flowID: Utils.makeGUID(),
-  };
-  let responder = new BookmarkRepairResponder();
-  await responder.repair(request, null);
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "uploading",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "5"},
-    },
-  ]);
-
-  _("Sync after requesting repair; should upload missing records");
-  await Service.sync();
-  deepEqual(getServerBookmarks(server).keys().sort(), [
-    ...initialRecordIds,
-    childBmk.recordId,
-    grandChildBmk.recordId,
-    grandChildSiblingBmk.recordId,
-  ].sort(), "Third sync should upload requested items");
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "finished",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "5"},
-    },
-  ]);
-
-  await cleanup(server);
-});
-
-// Error handling.
-add_task(async function test_aborts_unknown_request() {
-  let server = await makeServer();
-
-  let request = {
-    request: "not-upload",
-    ids: [],
-    flowID: Utils.makeGUID(),
-  };
-  let responder = new BookmarkRepairResponder();
-  await responder.repair(request, null);
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "aborted",
-      value: undefined,
-      extra: { flowID: request.flowID,
-               reason: "Don't understand request type 'not-upload'",
-             },
-    },
-  ]);
-  await cleanup(server);
-});
-
-add_task(async function test_upload_fail() {
-  let server = await makeServer();
-
-  // Pretend we've already synced this bookmark, so that we can ensure it's
-  // uploaded in response to our repair request.
-  let bm = await PlacesUtils.bookmarks.insert({ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
-                                                title: "Get Firefox",
-                                                url: "http://getfirefox.com/",
-                                                source: PlacesUtils.bookmarks.SOURCES.SYNC });
-
-  await Service.sync();
-  let request = {
-    request: "upload",
-    ids: [bm.guid],
-    flowID: Utils.makeGUID(),
-  };
-  let responder = new BookmarkRepairResponder();
-  await responder.repair(request, null);
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "uploading",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "1"},
-    },
-  ]);
-
-  // This sync would normally upload the item - arrange for it to fail.
-  let engine = Service.engineManager.get("bookmarks");
-  let oldCreateRecord = engine._createRecord;
-  engine._createRecord = async function(id) {
-    return "anything"; // doesn't have an "encrypt"
-  };
-
-  let numFailures = 0;
-  let numSuccesses = 0;
-  function onUploaded(subject, data) {
-    if (data != "bookmarks") {
-      return;
-    }
-    if (subject.failed) {
-      numFailures += 1;
-    } else {
-      numSuccesses += 1;
-    }
-  }
-  Svc.Obs.add("weave:engine:sync:uploaded", onUploaded, this);
-
-  await Service.sync();
-
-  equal(numFailures, 1);
-  equal(numSuccesses, 0);
-
-  // should be no recorded events
-  checkRecordedEvents([]);
-
-  // restore the error injection so next sync succeeds - the repair should
-  // restart
-  engine._createRecord = oldCreateRecord;
-  await responder.repair(request, null);
-
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "uploading",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "1"},
-    },
-  ]);
-
-  await Service.sync();
-  checkRecordedEvents([
-    { object: "repairResponse",
-      method: "finished",
-      value: undefined,
-      extra: {flowID: request.flowID, numIDs: "1"},
-    },
-  ]);
-
-  equal(numFailures, 1);
-  equal(numSuccesses, 1);
-
-  Svc.Obs.remove("weave:engine:sync:uploaded", onUploaded, this);
-  await cleanup(server);
-});
-
-add_task(async function teardown() {
-  Svc.Prefs.reset("engine.bookmarks.validation.enabled");
-});
deleted file mode 100644
--- a/services/sync/tests/unit/test_bookmark_validator.js
+++ /dev/null
@@ -1,448 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-Components.utils.import("resource://services-sync/bookmark_validator.js");
-Components.utils.import("resource://services-sync/util.js");
-
-function run_test() {
-  do_get_profile();
-  run_next_test();
-}
-
-async function inspectServerRecords(data) {
-  let validator = new BookmarkValidator();
-  return validator.inspectServerRecords(data);
-}
-
-async function compareServerWithClient(server, client) {
-  let validator = new BookmarkValidator();
-  return validator.compareServerWithClient(server, client);
-}
-
-add_task(async function test_isr_rootOnServer() {
-  let c = await inspectServerRecords([{
-    id: "places",
-    type: "folder",
-    children: [],
-  }]);
-  ok(c.problemData.rootOnServer);
-});
-
-add_task(async function test_isr_empty() {
-  let c = await inspectServerRecords([]);
-  ok(!c.problemData.rootOnServer);
-  notEqual(c.root, null);
-});
-
-add_task(async function test_isr_cycles() {
-  let c = (await inspectServerRecords([
-    {id: "C", type: "folder", children: ["A", "B"], parentid: "places"},
-    {id: "A", type: "folder", children: ["B"], parentid: "B"},
-    {id: "B", type: "folder", children: ["A"], parentid: "A"},
-  ])).problemData;
-
-  equal(c.cycles.length, 1);
-  ok(c.cycles[0].indexOf("A") >= 0);
-  ok(c.cycles[0].indexOf("B") >= 0);
-});
-
-add_task(async function test_isr_orphansMultiParents() {
-  let c = (await inspectServerRecords([
-    { id: "A", type: "bookmark", parentid: "D" },
-    { id: "B", type: "folder", parentid: "places", children: ["A"]},
-    { id: "C", type: "folder", parentid: "places", children: ["A"]},
-
-  ])).problemData;
-  deepEqual(c.orphans, [{ id: "A", parent: "D" }]);
-  equal(c.multipleParents.length, 1);
-  ok(c.multipleParents[0].parents.indexOf("B") >= 0);
-  ok(c.multipleParents[0].parents.indexOf("C") >= 0);
-});
-
-add_task(async function test_isr_orphansMultiParents2() {
-  let c = (await inspectServerRecords([
-    { id: "A", type: "bookmark", parentid: "D" },
-    { id: "B", type: "folder", parentid: "places", children: ["A"]},
-  ])).problemData;
-  equal(c.orphans.length, 1);
-  equal(c.orphans[0].id, "A");
-  equal(c.multipleParents.length, 0);
-});
-
-add_task(async function test_isr_deletedParents() {
-  let c = (await inspectServerRecords([
-    { id: "A", type: "bookmark", parentid: "B" },
-    { id: "B", type: "folder", parentid: "places", children: ["A"]},
-    { id: "B", type: "item", deleted: true},
-  ])).problemData;
-  deepEqual(c.deletedParents, ["A"]);
-});
-
-add_task(async function test_isr_badChildren() {
-  let c = (await inspectServerRecords([
-    { id: "A", type: "bookmark", parentid: "places", children: ["B", "C"] },
-    { id: "C", type: "bookmark", parentid: "A" }
-  ])).problemData;
-  deepEqual(c.childrenOnNonFolder, ["A"]);
-  deepEqual(c.missingChildren, [{parent: "A", child: "B"}]);
-  deepEqual(c.parentNotFolder, ["C"]);
-});
-
-
-add_task(async function test_isr_parentChildMismatches() {
-  let c = (await inspectServerRecords([
-    { id: "A", type: "folder", parentid: "places", children: [] },
-    { id: "B", type: "bookmark", parentid: "A" }
-  ])).problemData;
-  deepEqual(c.parentChildMismatches, [{parent: "A", child: "B"}]);
-});
-
-add_task(async function test_isr_duplicatesAndMissingIDs() {
-  let c = (await inspectServerRecords([
-    {id: "A", type: "folder", parentid: "places", children: []},
-    {id: "A", type: "folder", parentid: "places", children: []},
-    {type: "folder", parentid: "places", children: []}
-  ])).problemData;
-  equal(c.missingIDs, 1);
-  deepEqual(c.duplicates, ["A"]);
-});
-
-add_task(async function test_isr_duplicateChildren() {
-  let c = (await inspectServerRecords([
-    {id: "A", type: "folder", parentid: "places", children: ["B", "B"]},
-    {id: "B", type: "bookmark", parentid: "A"},
-  ])).problemData;
-  deepEqual(c.duplicateChildren, ["A"]);
-});
-
-// Each compareServerWithClient test mutates these, so we can"t just keep them
-// global
-function getDummyServerAndClient() {
-  let server = [
-    {
-      id: "menu",
-      parentid: "places",
-      type: "folder",
-      parentName: "",
-      title: "foo",
-      children: ["bbbbbbbbbbbb", "cccccccccccc"]
-    },
-    {
-      id: "bbbbbbbbbbbb",
-      type: "bookmark",
-      parentid: "menu",
-      parentName: "foo",
-      title: "bar",
-      bmkUri: "http://baz.com"
-    },
-    {
-      id: "cccccccccccc",
-      parentid: "menu",
-      parentName: "foo",
-      title: "",
-      type: "query",
-      bmkUri: "place:type=6&sort=14&maxResults=10"
-    }
-  ];
-
-  let client = {
-    "guid": "root________",
-    "title": "",
-    "id": 1,
-    "type": "text/x-moz-place-container",
-    "children": [
-      {
-        "guid": "menu________",
-        "title": "foo",
-        "id": 1000,
-        "type": "text/x-moz-place-container",
-        "children": [
-          {
-            "guid": "bbbbbbbbbbbb",
-            "title": "bar",
-            "id": 1001,
-            "type": "text/x-moz-place",
-            "uri": "http://baz.com"
-          },
-          {
-            "guid": "cccccccccccc",
-            "title": "",
-            "id": 1002,
-            "annos": [{
-              "name": "Places/SmartBookmark",
-              "flags": 0,
-              "expires": 4,
-              "value": "RecentTags"
-            }],
-            "type": "text/x-moz-place",
-            "uri": "place:type=6&sort=14&maxResults=10"
-          }
-        ]
-      }
-    ]
-  };
-  return {server, client};
-}
-
-
-add_task(async function test_cswc_valid() {
-  let {server, client} = getDummyServerAndClient();
-
-  let c = (await compareServerWithClient(server, client)).problemData;
-  equal(c.clientMissing.length, 0);
-  equal(c.serverMissing.length, 0);
-  equal(c.differences.length, 0);
-});
-
-add_task(async function test_cswc_serverMissing() {
-  let {server, client} = getDummyServerAndClient();
-  // remove c
-  server.pop();
-  server[0].children.pop();
-
-  let c = (await compareServerWithClient(server, client)).problemData;
-  deepEqual(c.serverMissing, ["cccccccccccc"]);
-  equal(c.clientMissing.length, 0);
-  deepEqual(c.structuralDifferences, [{id: "menu", differences: ["childGUIDs"]}]);
-});
-
-add_task(async function test_cswc_clientMissing() {
-  let {server, client} = getDummyServerAndClient();
-  client.children[0].children.pop();
-
-  let c = (await compareServerWithClient(server, client)).problemData;
-  deepEqual(c.clientMissing, ["cccccccccccc"]);
-  equal(c.serverMissing.length, 0);
-  deepEqual(c.structuralDifferences, [{id: "menu", differences: ["childGUIDs"]}]);
-});
-
-add_task(async function test_cswc_differences() {
-  {
-    let {server, client} = getDummyServerAndClient();
-    client.children[0].children[0].title = "asdf";
-    let c = (await compareServerWithClient(server, client)).problemData;
-    equal(c.clientMissing.length, 0);
-    equal(c.serverMissing.length, 0);
-    deepEqual(c.differences, [{id: "bbbbbbbbbbbb", differences: ["title"]}]);
-  }
-
-  {
-    let {server, client} = getDummyServerAndClient();
-    server[2].type = "bookmark";
-    let c = (await compareServerWithClient(server, client)).problemData;
-    equal(c.clientMissing.length, 0);
-    equal(c.serverMissing.length, 0);
-    deepEqual(c.differences, [{id: "cccccccccccc", differences: ["type"]}]);
-  }
-});
-
-add_task(async function test_cswc_differentURLs() {
-  let {server, client} = getDummyServerAndClient();
-  client.children[0].children.push({
-    guid: "dddddddddddd",
-    title: "Tag query",
-    "type": "text/x-moz-place",
-    "uri": "place:type=7&folder=80",
-  }, {
-    guid: "eeeeeeeeeeee",
-    title: "Firefox",
-    "type": "text/x-moz-place",
-    "uri": "http://getfirefox.com",
-  });
-  server.push({
-    id: "dddddddddddd",
-    parentid: "menu",
-    parentName: "foo",
-    title: "Tag query",
-    type: "query",
-    folderName: "taggy",
-    bmkUri: "place:type=7&folder=90",
-  }, {
-    id: "eeeeeeeeeeee",
-    parentid: "menu",
-    parentName: "foo",
-    title: "Firefox",
-    type: "bookmark",
-    bmkUri: "https://mozilla.org/firefox",
-  });
-
-  let c = (await compareServerWithClient(server, client)).problemData;
-  equal(c.differences.length, 1);
-  deepEqual(c.differences, [{
-    id: "eeeeeeeeeeee",
-    differences: ["bmkUri"],
-  }]);
-});
-
-add_task(async function test_cswc_serverUnexpected() {
-  let {server, client} = getDummyServerAndClient();
-  client.children.push({
-    "guid": "dddddddddddd",
-    "title": "",
-    "id": 2000,
-    "annos": [{
-      "name": "places/excludeFromBackup",
-      "flags": 0,
-      "expires": 4,
-      "value": 1
-    }, {
-      "name": "PlacesOrganizer/OrganizerFolder",
-      "flags": 0,
-      "expires": 4,
-      "value": 7
-    }],
-    "type": "text/x-moz-place-container",
-    "children": [{
-      "guid": "eeeeeeeeeeee",
-      "title": "History",
-      "annos": [{
-        "name": "places/excludeFromBackup",
-        "flags": 0,
-        "expires": 4,
-        "value": 1
-      }, {
-        "name": "PlacesOrganizer/OrganizerQuery",
-        "flags": 0,
-        "expires": 4,
-        "value": "History"
-      }],
-      "type": "text/x-moz-place",
-      "uri": "place:type=3&sort=4"
-    }]
-  });
-  server.push({
-    id: "dddddddddddd",
-    parentid: "places",
-    parentName: "",
-    title: "",
-    type: "folder",
-    children: ["eeeeeeeeeeee"]
-  }, {
-    id: "eeeeeeeeeeee",
-    parentid: "dddddddddddd",
-    parentName: "",
-    title: "History",
-    type: "query",
-    bmkUri: "place:type=3&sort=4"
-  });
-
-  let c = (await compareServerWithClient(server, client)).problemData;
-  equal(c.clientMissing.length, 0);
-  equal(c.serverMissing.length, 0);
-  equal(c.serverUnexpected.length, 2);
-  deepEqual(c.serverUnexpected, ["dddddddddddd", "eeeeeeeeeeee"]);
-});
-
-add_task(async function test_cswc_clientCycles() {
-  await PlacesUtils.bookmarks.insertTree({
-    guid: PlacesUtils.bookmarks.menuGuid,
-    children: [{
-      // A query for the menu, referenced by its local ID instead of
-      // `BOOKMARKS_MENU`. This should be reported as a cycle.
-      guid: "dddddddddddd",
-      url: `place:folder=${PlacesUtils.bookmarksMenuFolderId}`,
-      title: "Bookmarks Menu",
-    }, {
-      // A query that references the menu, but excludes itself, so it can't
-      // form a cycle.
-      guid: "iiiiiiiiiiii",
-      url: `place:folder=BOOKMARKS_MENU&folder=UNFILED_BOOKMARKS&` +
-           `folder=TOOLBAR&queryType=1&sort=12&maxResults=10&` +
-           `excludeQueries=1`,
-      title: "Recently Bookmarked",
-    }],
-  });
-
-  await PlacesUtils.bookmarks.insertTree({
-    guid: PlacesUtils.bookmarks.toolbarGuid,
-    children: [{
-      guid: "eeeeeeeeeeee",
-      type: PlacesUtils.bookmarks.TYPE_FOLDER,
-      children: [{
-        // A query for the toolbar in a subfolder. This should still be reported
-        // as a cycle.
-        guid: "ffffffffffff",
-        url: "place:folder=TOOLBAR&sort=3",
-        title: "Bookmarks Toolbar",
-      }],
-    }],
-  });
-
-  await PlacesUtils.bookmarks.insertTree({
-    guid: PlacesUtils.bookmarks.unfiledGuid,
-    children: [{
-      // A query for the menu. This shouldn't be reported as a cycle, since it
-      // references a different root.
-      guid: "gggggggggggg",
-      url: "place:folder=BOOKMARKS_MENU&sort=5",
-      title: "Bookmarks Menu",
-    }],
-  });
-
-  await PlacesUtils.bookmarks.insertTree({
-    guid: PlacesUtils.bookmarks.mobileGuid,
-    children: [{
-      // A query referencing multiple roots, one of which forms a cycle by
-      // referencing mobile. This is extremely unlikely, but it's cheap to
-      // detect, so we still report it.
-      guid: "hhhhhhhhhhhh",
-      url: "place:folder=TOOLBAR&folder=MOBILE_BOOKMARKS&folder=UNFILED_BOOKMARKS&sort=1",
-      title: "Toolbar, Mobile, Unfiled",
-    }],
-  });
-
-  let clientTree = await PlacesUtils.promiseBookmarksTree("", {
-    includeItemIds: true
-  });
-
-  let c = (await compareServerWithClient([], clientTree)).problemData;
-  deepEqual(c.clientCycles, [
-    ["menu", "dddddddddddd"],
-    ["toolbar", "eeeeeeeeeeee", "ffffffffffff"],
-    ["mobile", "hhhhhhhhhhhh"],
-  ]);
-});
-
-async function validationPing(server, client, duration) {
-  let pingPromise = wait_for_ping(() => {}, true); // Allow "failing" pings, since having validation info indicates failure.
-  // fake this entirely
-  Svc.Obs.notify("weave:service:sync:start");
-  Svc.Obs.notify("weave:engine:sync:start", null, "bookmarks");
-  Svc.Obs.notify("weave:engine:sync:finish", null, "bookmarks");
-  let validator = new BookmarkValidator();
-  let {problemData} = await validator.compareServerWithClient(server, client);
-  let data = {
-    // We fake duration and version just so that we can verify they"re passed through.
-    duration,
-    version: validator.version,
-    recordCount: server.length,
-    problems: problemData,
-  };
-  Svc.Obs.notify("weave:engine:validate:finish", data, "bookmarks");
-  Svc.Obs.notify("weave:service:sync:finish");
-  return pingPromise;
-}
-
-add_task(async function test_telemetry_integration() {
-  let {server, client} = getDummyServerAndClient();
-  // remove "c"
-  server.pop();
-  server[0].children.pop();
-  const duration = 50;
-  let ping = await validationPing(server, client, duration);
-  ok(ping.engines);
-  let bme = ping.engines.find(e => e.name === "bookmarks");
-  ok(bme);
-  ok(bme.validation);
-  ok(bme.validation.problems);
-  equal(bme.validation.checked, server.length);
-  equal(bme.validation.took, duration);
-  bme.validation.problems.sort((a, b) => String(a.name).localeCompare(b.name));
-  equal(bme.validation.version, new BookmarkValidator().version);
-  deepEqual(bme.validation.problems, [
-    { name: "badClientRoots", count: 3 },
-    { name: "sdiff:childGUIDs", count: 1 },
-    { name: "serverMissing", count: 1 },
-    { name: "structuralDifferences", count: 1 },
-  ]);
-});
--- a/services/sync/tests/unit/test_collections_recovery.js
+++ b/services/sync/tests/unit/test_collections_recovery.js
@@ -4,17 +4,17 @@
 // Verify that we wipe the server if we have to regenerate keys.
 Cu.import("resource://services-sync/service.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://testing-common/services/sync/utils.js");
 
 initTestLogging("Trace");
 
 add_task(async function test_missing_crypto_collection() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let johnHelper = track_collections_helper();
   let johnU      = johnHelper.with_updated_collection;
   let johnColls  = johnHelper.collections;
 
   let empty = false;
   function maybe_empty(handler) {
     return function(request, response) {
--- a/services/sync/tests/unit/test_corrupt_keys.js
+++ b/services/sync/tests/unit/test_corrupt_keys.js
@@ -10,17 +10,17 @@ Cu.import("resource://services-sync/engi
 Cu.import("resource://services-sync/engines/history.js");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/service.js");
 Cu.import("resource://services-sync/status.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://testing-common/services/sync/utils.js");
 
 add_task(async function test_locally_changed_keys() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let hmacErrorCount = 0;
   function counting(f) {
     return async function() {
       hmacErrorCount++;
       return f.call(this);
     };
   }
deleted file mode 100644
--- a/services/sync/tests/unit/test_doctor.js
+++ /dev/null
@@ -1,188 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-const { Doctor, REPAIR_ADVANCE_PERIOD } = Cu.import("resource://services-sync/doctor.js", {});
-Cu.import("resource://gre/modules/Services.jsm");
-
-initTestLogging("Trace");
-
-function mockDoctor(mocks) {
-  // Clone the object and put mocks in that.
-  return Object.assign({}, Doctor, mocks);
-}
-
-add_task(async function test_validation_interval() {
-  let now = 1000;
-  let doctor = mockDoctor({
-    _now() {
-      // note that the function being mocked actually returns seconds.
-      return now;
-    },
-  });
-
-  let engine = {
-    name: "test-engine",
-    getValidator() {
-      return {
-        validate(e) {
-          return {};
-        }
-      };
-    },
-  };
-
-  // setup prefs which enable test-engine validation.
-  Services.prefs.setBoolPref("services.sync.engine.test-engine.validation.enabled", true);
-  Services.prefs.setIntPref("services.sync.engine.test-engine.validation.percentageChance", 100);
-  Services.prefs.setIntPref("services.sync.engine.test-engine.validation.maxRecords", 1);
-  // And say we should validate every 10 seconds.
-  Services.prefs.setIntPref("services.sync.engine.test-engine.validation.interval", 10);
-
-  deepEqual(doctor._getEnginesToValidate([engine]), {
-    "test-engine": {
-      engine,
-      maxRecords: 1,
-    }
-  });
-  // We haven't advanced the timestamp, so we should not validate again.
-  deepEqual(doctor._getEnginesToValidate([engine]), {});
-  // Advance our clock by 11 seconds.
-  now += 11;
-  // We should validate again.
-  deepEqual(doctor._getEnginesToValidate([engine]), {
-    "test-engine": {
-      engine,
-      maxRecords: 1,
-    }
-  });
-});
-
-add_task(async function test_repairs_start() {
-  let repairStarted = false;
-  let problems = {
-    missingChildren: ["a", "b", "c"],
-  };
-  let validator = {
-    validate(engine) {
-      return problems;
-    },
-    canValidate() {
-      return Promise.resolve(true);
-    }
-  };
-  let engine = {
-    name: "test-engine",
-    getValidator() {
-      return validator;
-    }
-  };
-  let requestor = {
-    async startRepairs(validationInfo, flowID) {
-      ok(flowID, "got a flow ID");
-      equal(validationInfo, problems);
-      repairStarted = true;
-      return true;
-    },
-    tryServerOnlyRepairs() {
-      return false;
-    }
-  };
-  let doctor = mockDoctor({
-    _getEnginesToValidate(recentlySyncedEngines) {
-      deepEqual(recentlySyncedEngines, [engine]);
-      return {
-        "test-engine": { engine, maxRecords: -1 }
-      };
-    },
-    _getRepairRequestor(engineName) {
-      equal(engineName, engine.name);
-      return requestor;
-    },
-    _shouldRepair(e) {
-      return true;
-    },
-  });
-  let promiseValidationDone = promiseOneObserver("weave:engine:validate:finish");
-  await doctor.consult([engine]);
-  await promiseValidationDone;
-  ok(repairStarted);
-});
-
-add_task(async function test_repairs_advanced_daily() {
-  let repairCalls = 0;
-  let requestor = {
-    async continueRepairs() {
-      repairCalls++;
-    },
-    tryServerOnlyRepairs() {
-      return false;
-    }
-  };
-  // start now at just after REPAIR_ADVANCE_PERIOD so we do a a first one.
-  let now = REPAIR_ADVANCE_PERIOD + 1;
-  let doctor = mockDoctor({
-    _getEnginesToValidate() {
-      return {}; // no validations.
-    },
-    _runValidators() {
-      // do nothing.
-    },
-    _getAllRepairRequestors() {
-      return {
-        foo: requestor,
-      };
-    },
-    _now() {
-      return now;
-    },
-  });
-  await doctor.consult();
-  equal(repairCalls, 1);
-  now += 10; // 10 seconds later...
-  await doctor.consult();
-  // should not have done another repair yet - it's too soon.
-  equal(repairCalls, 1);
-  // advance our pretend clock by the advance period (eg, day)
-  now += REPAIR_ADVANCE_PERIOD;
-  await doctor.consult();
-  // should have done another repair
-  equal(repairCalls, 2);
-});
-
-add_task(async function test_repairs_skip_if_cant_vaidate() {
-  let validator = {
-    canValidate() {
-      return Promise.resolve(false);
-    },
-    validate() {
-      ok(false, "Shouldn't validate");
-    }
-  };
-  let engine = {
-    name: "test-engine",
-    getValidator() {
-      return validator;
-    }
-  };
-  let requestor = {
-    async startRepairs(validationInfo, flowID) {
-      ok(false, "Never should start repairs");
-    },
-    tryServerOnlyRepairs() {
-      return false;
-    }
-  };
-  let doctor = mockDoctor({
-    _getEnginesToValidate(recentlySyncedEngines) {
-      deepEqual(recentlySyncedEngines, [engine]);
-      return {
-        "test-engine": { engine, maxRecords: -1 }
-      };
-    },
-    _getRepairRequestor(engineName) {
-      equal(engineName, engine.name);
-      return requestor;
-    },
-  });
-  await doctor.consult([engine]);
-});
--- a/services/sync/tests/unit/test_engine_changes_during_sync.js
+++ b/services/sync/tests/unit/test_engine_changes_during_sync.js
@@ -31,17 +31,17 @@ async function cleanup(engine, server) {
   Svc.Prefs.resetBranch("");
   Service.recordManager.clearCache();
   await promiseStopServer(server);
 }
 
 add_task(async function test_history_change_during_sync() {
   _("Ensure that we don't bump the score when applying history records.");
 
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let engine = Service.engineManager.get("history");
   let server = await serverForEnginesWithKeys({"foo": "password"}, [engine]);
   await SyncTestingInfrastructure(server);
   let collection = server.user("foo").collection("history");
 
   // Override `uploadOutgoing` to insert a record while we're applying
   // changes. The tracker should ignore this change.
@@ -85,17 +85,17 @@ add_task(async function test_history_cha
     engine._uploadOutgoing = uploadOutgoing;
     await cleanup(engine, server);
   }
 });
 
 add_task(async function test_passwords_change_during_sync() {
   _("Ensure that we don't bump the score when applying passwords.");
 
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let engine = Service.engineManager.get("passwords");
   let server = await serverForEnginesWithKeys({"foo": "password"}, [engine]);
   await SyncTestingInfrastructure(server);
   let collection = server.user("foo").collection("passwords");
 
   let uploadOutgoing = engine._uploadOutgoing;
   engine._uploadOutgoing = async function() {
@@ -142,17 +142,17 @@ add_task(async function test_passwords_c
   }
 });
 
 add_task(async function test_prefs_change_during_sync() {
   _("Ensure that we don't bump the score when applying prefs.");
 
   const TEST_PREF = "services.sync.prefs.sync.test.duringSync";
 
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let engine = Service.engineManager.get("prefs");
   let server = await serverForEnginesWithKeys({"foo": "password"}, [engine]);
   await SyncTestingInfrastructure(server);
   let collection = server.user("foo").collection("prefs");
 
   let uploadOutgoing = engine._uploadOutgoing;
   engine._uploadOutgoing = async function() {
@@ -201,17 +201,17 @@ add_task(async function test_prefs_chang
     await cleanup(engine, server);
     Services.prefs.clearUserPref(TEST_PREF);
   }
 });
 
 add_task(async function test_forms_change_during_sync() {
   _("Ensure that we don't bump the score when applying form records.");
 
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let engine = Service.engineManager.get("forms");
   let server = await serverForEnginesWithKeys({"foo": "password"}, [engine]);
   await SyncTestingInfrastructure(server);
   let collection = server.user("foo").collection("forms");
 
   let uploadOutgoing = engine._uploadOutgoing;
   engine._uploadOutgoing = async function() {
@@ -259,17 +259,17 @@ add_task(async function test_forms_chang
     engine._uploadOutgoing = uploadOutgoing;
     await cleanup(engine, server);
   }
 });
 
 add_task(async function test_bookmark_change_during_sync() {
   _("Ensure that we track bookmark changes made during a sync.");
 
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Already-tracked bookmarks that shouldn't be uploaded during the first sync.
   let bzBmk = await PlacesUtils.bookmarks.insert({
     parentGuid: PlacesUtils.bookmarks.menuGuid,
     url: "https://bugzilla.mozilla.org/",
     title: "Bugzilla",
   });
   _(`Bugzilla GUID: ${bzBmk.guid}`);
--- a/services/sync/tests/unit/test_errorhandler_1.js
+++ b/services/sync/tests/unit/test_errorhandler_1.js
@@ -59,17 +59,17 @@ async function clean() {
   await Service.startOver();
   await promiseLogReset;
   Status.resetSync();
   Status.resetBackoff();
   errorHandler.didReportProlongedError = false;
 }
 
 add_task(async function test_401_logout() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   // By calling sync, we ensure we're logged in.
   await sync_and_validate_telem();
   do_check_eq(Status.sync, SYNC_SUCCEEDED);
   do_check_true(Service.isLoggedIn);
@@ -104,17 +104,17 @@ add_task(async function test_401_logout(
   do_check_false(Service.isLoggedIn);
 
   // Clean up.
   await Service.startOver();
   await promiseStopServer(server);
 });
 
 add_task(async function test_credentials_changed_logout() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   // By calling sync, we ensure we're logged in.
   await sync_and_validate_telem();
   do_check_eq(Status.sync, SYNC_SUCCEEDED);
   do_check_true(Service.isLoggedIn);
@@ -359,17 +359,17 @@ add_task(function test_shouldReportLogin
   do_check_true(errorHandler.shouldReportError());
   // But any other status with a missing clusterURL is treated as a mid-sync
   // 401 (ie, should be treated as a node reassignment)
   Status.login = LOGIN_SUCCEEDED;
   do_check_false(errorHandler.shouldReportError());
 });
 
 add_task(async function test_login_syncAndReportErrors_non_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test non-network errors are reported
   // when calling syncAndReportErrors
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
   Service.identity.resetSyncKeyBundle();
 
   let promiseObserved = promiseOneObserver("weave:ui:login:error");
@@ -379,17 +379,17 @@ add_task(async function test_login_syncA
   await promiseObserved;
   do_check_eq(Status.login, LOGIN_FAILED_NO_PASSPHRASE);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_sync_syncAndReportErrors_non_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test non-network errors are reported
   // when calling syncAndReportErrors
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   // By calling sync, we ensure we're logged in.
   await Service.sync();
@@ -412,17 +412,17 @@ add_task(async function test_sync_syncAn
   do_check_eq(Status.sync, CREDENTIALS_CHANGED);
   // If we clean this tick, telemetry won't get the right error
   await Async.promiseYield();
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_login_syncAndReportErrors_prolonged_non_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test prolonged, non-network errors are
   // reported when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
   Service.identity.resetSyncKeyBundle();
 
   let promiseObserved = promiseOneObserver("weave:ui:login:error");
@@ -432,17 +432,17 @@ add_task(async function test_login_syncA
   await promiseObserved;
   do_check_eq(Status.login, LOGIN_FAILED_NO_PASSPHRASE);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_sync_syncAndReportErrors_prolonged_non_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test prolonged, non-network errors are
   // reported when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   // By calling sync, we ensure we're logged in.
   await Service.sync();
@@ -465,17 +465,17 @@ add_task(async function test_sync_syncAn
   do_check_eq(Status.sync, CREDENTIALS_CHANGED);
   // If we clean this tick, telemetry won't get the right error
   await Async.promiseYield();
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_login_syncAndReportErrors_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test network errors are reported when calling syncAndReportErrors.
   await configureIdentity({username: "broken.wipe"});
   Service.clusterURL = fakeServerUrl;
 
   let promiseObserved = promiseOneObserver("weave:ui:login:error");
 
   setLastSync(NON_PROLONGED_ERROR_DURATION);
@@ -484,34 +484,34 @@ add_task(async function test_login_syncA
 
   do_check_eq(Status.login, LOGIN_FAILED_NETWORK_ERROR);
 
   await clean();
 });
 
 
 add_task(async function test_sync_syncAndReportErrors_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test network errors are reported when calling syncAndReportErrors.
   Services.io.offline = true;
 
   let promiseUISyncError = promiseOneObserver("weave:ui:sync:error");
 
   setLastSync(NON_PROLONGED_ERROR_DURATION);
   errorHandler.syncAndReportErrors();
   await promiseUISyncError;
   do_check_eq(Status.sync, LOGIN_FAILED_NETWORK_ERROR);
 
   Services.io.offline = false;
   await clean();
 });
 
 add_task(async function test_login_syncAndReportErrors_prolonged_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test prolonged, network errors are reported
   // when calling syncAndReportErrors.
   await configureIdentity({username: "johndoe"});
 
   Service.clusterURL = fakeServerUrl;
 
   let promiseObserved = promiseOneObserver("weave:ui:login:error");
@@ -520,17 +520,17 @@ add_task(async function test_login_syncA
   errorHandler.syncAndReportErrors();
   await promiseObserved;
   do_check_eq(Status.login, LOGIN_FAILED_NETWORK_ERROR);
 
   await clean();
 });
 
 add_task(async function test_sync_syncAndReportErrors_prolonged_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test prolonged, network errors are reported
   // when calling syncAndReportErrors.
   Services.io.offline = true;
 
   let promiseUISyncError = promiseOneObserver("weave:ui:sync:error");
 
   setLastSync(PROLONGED_ERROR_DURATION);
@@ -538,17 +538,17 @@ add_task(async function test_sync_syncAn
   await promiseUISyncError;
   do_check_eq(Status.sync, LOGIN_FAILED_NETWORK_ERROR);
 
   Services.io.offline = false;
   await clean();
 });
 
 add_task(async function test_login_prolonged_non_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test prolonged, non-network errors are reported
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
   Service.identity.resetSyncKeyBundle();
 
   let promiseObserved = promiseOneObserver("weave:ui:login:error");
 
@@ -558,17 +558,17 @@ add_task(async function test_login_prolo
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_true(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_sync_prolonged_non_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test prolonged, non-network errors are reported
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   // By calling sync, we ensure we're logged in.
   await Service.sync();
   do_check_eq(Status.sync, SYNC_SUCCEEDED);
@@ -589,17 +589,17 @@ add_task(async function test_sync_prolon
   await promiseObserved;
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_true(errorHandler.didReportProlongedError);
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_login_prolonged_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test prolonged, network errors are reported
   await configureIdentity({username: "johndoe"});
   Service.clusterURL = fakeServerUrl;
 
   let promiseObserved = promiseOneObserver("weave:ui:login:error");
 
   setLastSync(PROLONGED_ERROR_DURATION);
@@ -607,17 +607,17 @@ add_task(async function test_login_prolo
   await promiseObserved;
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_true(errorHandler.didReportProlongedError);
 
   await clean();
 });
 
 add_task(async function test_sync_prolonged_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test prolonged, network errors are reported
   Services.io.offline = true;
 
   let promiseUISyncError = promiseOneObserver("weave:ui:sync:error");
 
   setLastSync(PROLONGED_ERROR_DURATION);
   await Service.sync();
@@ -625,17 +625,17 @@ add_task(async function test_sync_prolon
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_true(errorHandler.didReportProlongedError);
 
   Services.io.offline = false;
   await clean();
 });
 
 add_task(async function test_login_non_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test non-network errors are reported
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
   Service.identity.resetSyncKeyBundle();
 
   let promiseObserved = promiseOneObserver("weave:ui:login:error");
 
@@ -645,17 +645,17 @@ add_task(async function test_login_non_n
   do_check_eq(Status.login, LOGIN_FAILED_NO_PASSPHRASE);
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_sync_non_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test non-network errors are reported
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   // By calling sync, we ensure we're logged in.
   await Service.sync();
   do_check_eq(Status.sync, SYNC_SUCCEEDED);
@@ -671,17 +671,17 @@ add_task(async function test_sync_non_ne
   do_check_eq(Status.sync, CREDENTIALS_CHANGED);
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_login_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   await configureIdentity({username: "johndoe"});
   Service.clusterURL = fakeServerUrl;
 
   let promiseObserved = promiseOneObserver("weave:ui:clear-error");
   // Test network errors are not reported.
 
   setLastSync(NON_PROLONGED_ERROR_DURATION);
@@ -690,17 +690,17 @@ add_task(async function test_login_netwo
   do_check_eq(Status.login, LOGIN_FAILED_NETWORK_ERROR);
   do_check_false(errorHandler.didReportProlongedError);
 
   Services.io.offline = false;
   await clean();
 });
 
 add_task(async function test_sync_network_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test network errors are not reported.
   Services.io.offline = true;
 
   let promiseSyncFinished = promiseOneObserver("weave:ui:sync:finish");
 
   setLastSync(NON_PROLONGED_ERROR_DURATION);
   await Service.sync();
@@ -708,17 +708,17 @@ add_task(async function test_sync_networ
   do_check_eq(Status.sync, LOGIN_FAILED_NETWORK_ERROR);
   do_check_false(errorHandler.didReportProlongedError);
 
   Services.io.offline = false;
   await clean();
 });
 
 add_task(async function test_sync_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test server maintenance errors are not reported.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   const BACKOFF = 42;
   engine.enabled = true;
   engine.exception = {status: 503,
@@ -743,17 +743,17 @@ add_task(async function test_sync_server
   do_check_eq(Status.sync, SERVER_MAINTENANCE);
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_info_collections_login_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test info/collections server maintenance errors are not reported.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.info"}, server);
 
   let backoffInterval;
@@ -783,17 +783,17 @@ add_task(async function test_info_collec
   do_check_false(errorHandler.didReportProlongedError);
 
   Svc.Obs.remove("weave:ui:login:error", onUIUpdate);
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_meta_global_login_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test meta/global server maintenance errors are not reported.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.meta"}, server);
 
   let backoffInterval;
--- a/services/sync/tests/unit/test_errorhandler_2.js
+++ b/services/sync/tests/unit/test_errorhandler_2.js
@@ -94,17 +94,17 @@ async function clean() {
   await promiseLogReset;
   Status.resetSync();
   Status.resetBackoff();
   errorHandler.didReportProlongedError = false;
   removeLogFiles();
 }
 
 add_task(async function test_crypto_keys_login_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   Status.resetSync();
   // Test crypto/keys server maintenance errors are not reported.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.keys"}, server);
 
@@ -135,17 +135,17 @@ add_task(async function test_crypto_keys
   do_check_false(errorHandler.didReportProlongedError);
 
   Svc.Obs.remove("weave:ui:login:error", onUIUpdate);
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_sync_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test prolonged server maintenance errors are reported.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   const BACKOFF = 42;
   engine.enabled = true;
   engine.exception = {status: 503,
@@ -166,17 +166,17 @@ add_task(async function test_sync_prolon
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_true(errorHandler.didReportProlongedError);
 
   await promiseStopServer(server);
   await clean();
 });
 
 add_task(async function test_info_collections_login_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test info/collections prolonged server maintenance errors are reported.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.info"}, server);
 
   let backoffInterval;
@@ -197,17 +197,17 @@ add_task(async function test_info_collec
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_true(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_meta_global_login_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test meta/global prolonged server maintenance errors are reported.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.meta"}, server);
 
   let backoffInterval;
@@ -228,17 +228,17 @@ add_task(async function test_meta_global
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_true(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_download_crypto_keys_login_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test crypto/keys prolonged server maintenance errors are reported.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.keys"}, server);
   // Force re-download of keys
   Service.collectionKeys.clear();
@@ -260,17 +260,17 @@ add_task(async function test_download_cr
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_true(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_upload_crypto_keys_login_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test crypto/keys prolonged server maintenance errors are reported.
   let server = await EHTestsCommon.sync_httpd_setup();
 
   // Start off with an empty account, do not upload a key.
   await configureIdentity({username: "broken.keys"}, server);
 
   let backoffInterval;
@@ -291,17 +291,17 @@ add_task(async function test_upload_cryp
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_true(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_wipeServer_login_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test that we report prolonged server maintenance errors that occur whilst
   // wiping the server.
   let server = await EHTestsCommon.sync_httpd_setup();
 
   // Start off with an empty account, do not upload a key.
   await configureIdentity({username: "broken.wipe"}, server);
 
@@ -323,17 +323,17 @@ add_task(async function test_wipeServer_
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_true(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_wipeRemote_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test that we report prolonged server maintenance errors that occur whilst
   // wiping all remote devices.
   let server = await EHTestsCommon.sync_httpd_setup();
 
   server.registerPathHandler("/1.1/broken.wipe/storage/catapult", EHTestsCommon.service_unavailable);
   await configureIdentity({username: "broken.wipe"}, server);
   await EHTestsCommon.generateAndUploadKeys();
@@ -364,17 +364,17 @@ add_task(async function test_wipeRemote_
   do_check_eq(Status.sync, PROLONGED_SYNC_FAILURE);
   do_check_eq(Svc.Prefs.get("firstSync"), "wipeRemote");
   do_check_true(errorHandler.didReportProlongedError);
   await promiseStopServer(server);
   await clean();
 });
 
 add_task(async function test_sync_syncAndReportErrors_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   const BACKOFF = 42;
   engine.enabled = true;
@@ -390,17 +390,17 @@ add_task(async function test_sync_syncAn
   do_check_eq(Status.sync, SERVER_MAINTENANCE);
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_info_collections_login_syncAndReportErrors_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test info/collections server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.info"}, server);
 
@@ -422,17 +422,17 @@ add_task(async function test_info_collec
   do_check_eq(Status.login, SERVER_MAINTENANCE);
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_meta_global_login_syncAndReportErrors_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test meta/global server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.meta"}, server);
 
@@ -454,17 +454,17 @@ add_task(async function test_meta_global
   do_check_eq(Status.login, SERVER_MAINTENANCE);
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_download_crypto_keys_login_syncAndReportErrors_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test crypto/keys server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.keys"}, server);
   // Force re-download of keys
@@ -488,17 +488,17 @@ add_task(async function test_download_cr
   do_check_eq(Status.login, SERVER_MAINTENANCE);
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_upload_crypto_keys_login_syncAndReportErrors_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test crypto/keys server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
 
   // Start off with an empty account, do not upload a key.
   await configureIdentity({username: "broken.keys"}, server);
 
@@ -520,17 +520,17 @@ add_task(async function test_upload_cryp
   do_check_eq(Status.login, SERVER_MAINTENANCE);
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_wipeServer_login_syncAndReportErrors_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test crypto/keys server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
 
   // Start off with an empty account, do not upload a key.
   await configureIdentity({username: "broken.wipe"}, server);
 
@@ -552,17 +552,17 @@ add_task(async function test_wipeServer_
   do_check_eq(Status.login, SERVER_MAINTENANCE);
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_wipeRemote_syncAndReportErrors_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test that we report prolonged server maintenance errors that occur whilst
   // wiping all remote devices.
   let server = await EHTestsCommon.sync_httpd_setup();
 
   await configureIdentity({username: "broken.wipe"}, server);
   await EHTestsCommon.generateAndUploadKeys();
 
@@ -589,17 +589,17 @@ add_task(async function test_wipeRemote_
   do_check_eq(Svc.Prefs.get("firstSync"), "wipeRemote");
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_sync_syncAndReportErrors_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test prolonged server maintenance errors are
   // reported when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   const BACKOFF = 42;
   engine.enabled = true;
@@ -617,17 +617,17 @@ add_task(async function test_sync_syncAn
   // didReportProlongedError not touched.
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_info_collections_login_syncAndReportErrors_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test info/collections server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.info"}, server);
 
@@ -651,17 +651,17 @@ add_task(async function test_info_collec
   // didReportProlongedError not touched.
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_meta_global_login_syncAndReportErrors_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test meta/global server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.meta"}, server);
 
@@ -685,17 +685,17 @@ add_task(async function test_meta_global
   // didReportProlongedError not touched.
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_download_crypto_keys_login_syncAndReportErrors_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test crypto/keys server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
   await EHTestsCommon.setUp(server);
 
   await configureIdentity({username: "broken.keys"}, server);
   // Force re-download of keys
@@ -721,17 +721,17 @@ add_task(async function test_download_cr
   // didReportProlongedError not touched.
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_upload_crypto_keys_login_syncAndReportErrors_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test crypto/keys server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
 
   // Start off with an empty account, do not upload a key.
   await configureIdentity({username: "broken.keys"}, server);
 
@@ -755,17 +755,17 @@ add_task(async function test_upload_cryp
   // didReportProlongedError not touched.
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_wipeServer_login_syncAndReportErrors_prolonged_server_maintenance_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test crypto/keys server maintenance errors are reported
   // when calling syncAndReportErrors.
   let server = await EHTestsCommon.sync_httpd_setup();
 
   // Start off with an empty account, do not upload a key.
   await configureIdentity({username: "broken.wipe"}, server);
 
@@ -789,17 +789,17 @@ add_task(async function test_wipeServer_
   // didReportProlongedError not touched.
   do_check_false(errorHandler.didReportProlongedError);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_sync_engine_generic_fail() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   equal(getLogFiles().length, 0);
 
   let server = await EHTestsCommon.sync_httpd_setup();
   engine.enabled = true;
   engine.sync = async function sync() {
     Svc.Obs.notify("weave:engine:sync:error", ENGINE_UNKNOWN_FAIL, "catapult");
   };
@@ -842,17 +842,17 @@ add_task(async function test_sync_engine
   let syncErrors = sumHistogram("WEAVE_ENGINE_SYNC_ERRORS", { key: "catapult" });
   do_check_true(syncErrors, 1);
 
   await clean();
   await promiseStopServer(server);
 });
 
 add_task(async function test_logs_on_sync_error_despite_shouldReportError() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Ensure that an error is still logged when weave:service:sync:error " +
     "is notified, despite shouldReportError returning false.");
 
   let log = Log.repository.getLogger("Sync.ErrorHandler");
   Svc.Prefs.set("log.appender.file.logOnError", true);
   log.info("TESTING");
 
@@ -868,17 +868,17 @@ add_task(async function test_logs_on_syn
   let logFiles = getLogFiles();
   equal(logFiles.length, 1);
   do_check_true(logFiles[0].leafName.startsWith("error-sync-"), logFiles[0].leafName);
 
   await clean();
 });
 
 add_task(async function test_logs_on_login_error_despite_shouldReportError() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Ensure that an error is still logged when weave:service:login:error " +
     "is notified, despite shouldReportError returning false.");
 
   let log = Log.repository.getLogger("Sync.ErrorHandler");
   Svc.Prefs.set("log.appender.file.logOnError", true);
   log.info("TESTING");
 
@@ -896,17 +896,17 @@ add_task(async function test_logs_on_log
   do_check_true(logFiles[0].leafName.startsWith("error-sync-"), logFiles[0].leafName);
 
   await clean();
 });
 
 // This test should be the last one since it monkeypatches the engine object
 // and we should only have one engine object throughout the file (bug 629664).
 add_task(async function test_engine_applyFailed() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = await EHTestsCommon.sync_httpd_setup();
 
   engine.enabled = true;
   delete engine.exception;
   engine.sync = async function sync() {
     Svc.Obs.notify("weave:engine:sync:applied", {newFailed: 1}, "catapult");
   };
--- a/services/sync/tests/unit/test_errorhandler_sync_checkServerError.js
+++ b/services/sync/tests/unit/test_errorhandler_sync_checkServerError.js
@@ -66,17 +66,17 @@ async function generateAndUploadKeys(ser
 }
 
 add_task(async function run_test() {
   validate_all_future_pings();
   await engineManager.register(CatapultEngine);
 });
 
 add_task(async function test_backoff500() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: HTTP 500 sets backoff status.");
   let server = await sync_httpd_setup();
   await setUp(server);
 
   let engine = engineManager.get("catapult");
   engine.enabled = true;
   engine.exception = {status: 500};
@@ -95,17 +95,17 @@ add_task(async function test_backoff500(
   } finally {
     Status.resetBackoff();
     await Service.startOver();
   }
   await promiseStopServer(server);
 });
 
 add_task(async function test_backoff503() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: HTTP 503 with Retry-After header leads to backoff notification and sets backoff status.");
   let server = await sync_httpd_setup();
   await setUp(server);
 
   const BACKOFF = 42;
   let engine = engineManager.get("catapult");
   engine.enabled = true;
@@ -133,17 +133,17 @@ add_task(async function test_backoff503(
     Status.resetBackoff();
     Status.resetSync();
     await Service.startOver();
   }
   await promiseStopServer(server);
 });
 
 add_task(async function test_overQuota() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: HTTP 400 with body error code 14 means over quota.");
   let server = await sync_httpd_setup();
   await setUp(server);
 
   let engine = engineManager.get("catapult");
   engine.enabled = true;
   engine.exception = {status: 400,
@@ -164,17 +164,17 @@ add_task(async function test_overQuota()
   } finally {
     Status.resetSync();
     await Service.startOver();
   }
   await promiseStopServer(server);
 });
 
 add_task(async function test_service_networkError() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Connection refused error from Service.sync() leads to the right status code.");
   let server = await sync_httpd_setup();
   await setUp(server);
   await promiseStopServer(server);
   // Provoke connection refused.
   Service.clusterURL = "http://localhost:12345/";
 
@@ -188,17 +188,17 @@ add_task(async function test_service_net
     do_check_eq(Status.service, SYNC_FAILED);
   } finally {
     Status.resetSync();
     await Service.startOver();
   }
 });
 
 add_task(async function test_service_offline() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Wanting to sync in offline mode leads to the right status code but does not increment the ignorable error count.");
   let server = await sync_httpd_setup();
   await setUp(server);
 
   await promiseStopServer(server);
   Services.io.offline = true;
   Services.prefs.setBoolPref("network.dns.offline-localhost", false);
@@ -215,17 +215,17 @@ add_task(async function test_service_off
     Status.resetSync();
     await Service.startOver();
   }
   Services.io.offline = false;
   Services.prefs.clearUserPref("network.dns.offline-localhost");
 });
 
 add_task(async function test_engine_networkError() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Network related exceptions from engine.sync() lead to the right status code.");
   let server = await sync_httpd_setup();
   await setUp(server);
 
   let engine = engineManager.get("catapult");
   engine.enabled = true;
   engine.exception = Components.Exception("NS_ERROR_UNKNOWN_HOST",
@@ -244,17 +244,17 @@ add_task(async function test_engine_netw
   } finally {
     Status.resetSync();
     await Service.startOver();
   }
   await promiseStopServer(server);
 });
 
 add_task(async function test_resource_timeout() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = await sync_httpd_setup();
   await setUp(server);
 
   let engine = engineManager.get("catapult");
   engine.enabled = true;
   // Resource throws this when it encounters a timeout.
   engine.exception = Components.Exception("Aborting due to channel inactivity.",
deleted file mode 100644
--- a/services/sync/tests/unit/test_form_validator.js
+++ /dev/null
@@ -1,85 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-Components.utils.import("resource://services-sync/engines/forms.js");
-
-function getDummyServerAndClient() {
-  return {
-    server: [
-      {
-        id: "11111",
-        guid: "11111",
-        name: "foo",
-        fieldname: "foo",
-        value: "bar",
-      },
-      {
-        id: "22222",
-        guid: "22222",
-        name: "foo2",
-        fieldname: "foo2",
-        value: "bar2",
-      },
-      {
-        id: "33333",
-        guid: "33333",
-        name: "foo3",
-        fieldname: "foo3",
-        value: "bar3",
-      },
-    ],
-    client: [
-      {
-        id: "11111",
-        guid: "11111",
-        name: "foo",
-        fieldname: "foo",
-        value: "bar",
-      },
-      {
-        id: "22222",
-        guid: "22222",
-        name: "foo2",
-        fieldname: "foo2",
-        value: "bar2",
-      },
-      {
-        id: "33333",
-        guid: "33333",
-        name: "foo3",
-        fieldname: "foo3",
-        value: "bar3",
-      }
-    ]
-  };
-}
-
-add_task(async function test_valid() {
-  let { server, client } = getDummyServerAndClient();
-  let validator = new FormValidator();
-  let { problemData, clientRecords, records, deletedRecords } =
-      await validator.compareClientWithServer(client, server);
-  equal(clientRecords.length, 3);
-  equal(records.length, 3);
-  equal(deletedRecords.length, 0);
-  deepEqual(problemData, validator.emptyProblemData());
-});
-
-
-add_task(async function test_formValidatorIgnoresMissingClients() {
-  // Since history form records are not deleted from the server, the
-  // |FormValidator| shouldn't set the |missingClient| flag in |problemData|.
-  let { server, client } = getDummyServerAndClient();
-  client.pop();
-
-  let validator = new FormValidator();
-  let { problemData, clientRecords, records, deletedRecords } =
-      await validator.compareClientWithServer(client, server);
-
-  equal(clientRecords.length, 2);
-  equal(records.length, 3);
-  equal(deletedRecords.length, 0);
-
-  let expected = validator.emptyProblemData();
-  deepEqual(problemData, expected);
-});
--- a/services/sync/tests/unit/test_fxa_node_reassignment.js
+++ b/services/sync/tests/unit/test_fxa_node_reassignment.js
@@ -170,17 +170,17 @@ async function syncAndExpectNodeReassign
   }
   await deferred.promise;
 }
 
 // Check that when we sync we don't request a new token by default - our
 // test setup has configured the client with a valid token, and that token
 // should be used to form the cluster URL.
 add_task(async function test_single_token_fetch() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a normal sync only fetches 1 token");
 
   let numTokenFetches = 0;
 
   function afterTokenFetch() {
     numTokenFetches++;
   }
@@ -202,17 +202,17 @@ add_task(async function test_single_toke
   // that clusterURL we expect.
   let expectedClusterURL = server.baseURI + "1.1/johndoe/";
   do_check_eq(Service.clusterURL, expectedClusterURL);
   await Service.startOver();
   await promiseStopServer(server);
 });
 
 add_task(async function test_momentary_401_engine() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a failure for engine URLs that's resolved by reassignment.");
   let server = await prepareServer();
   let john   = server.user("johndoe");
 
   _("Enabling the Rotary engine.");
   let { engine, tracker } = await registerRotaryEngine();
 
@@ -258,17 +258,17 @@ add_task(async function test_momentary_4
                                       Service.storageURL + "rotary");
 
   tracker.clearChangedIDs();
   Service.engineManager.unregister(engine);
 });
 
 // This test ends up being a failing info fetch *after we're already logged in*.
 add_task(async function test_momentary_401_info_collections_loggedin() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a failure for info/collections after login that's resolved by reassignment.");
   let server = await prepareServer();
 
   _("First sync to prepare server contents.");
   await Service.sync();
 
   _("Arrange for info/collections to return a 401.");
@@ -288,17 +288,17 @@ add_task(async function test_momentary_4
                                       "weave:service:sync:finish",
                                       Service.infoURL);
 });
 
 // This test ends up being a failing info fetch *before we're logged in*.
 // In this case we expect to recover during the login phase - so the first
 // sync succeeds.
 add_task(async function test_momentary_401_info_collections_loggedout() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a failure for info/collections before login that's resolved by reassignment.");
 
   let oldHandler;
   let sawTokenFetch = false;
 
   function afterTokenFetch() {
     // After a single token fetch, we undo our evil handleReassign hack, so
@@ -322,17 +322,17 @@ add_task(async function test_momentary_4
   do_check_true(sawTokenFetch, "a new token was fetched by this test.");
   // and we are done.
   await Service.startOver();
   await promiseStopServer(server);
 });
 
 // This test ends up being a failing meta/global fetch *after we're already logged in*.
 add_task(async function test_momentary_401_storage_loggedin() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a failure for any storage URL after login that's resolved by" +
     "reassignment.");
   let server = await prepareServer();
 
   _("First sync to prepare server contents.");
   await Service.sync();
 
@@ -351,17 +351,17 @@ add_task(async function test_momentary_4
                                       "weave:service:sync:error",
                                       undo,
                                       "weave:service:sync:finish",
                                       Service.storageURL + "meta/global");
 });
 
 // This test ends up being a failing meta/global fetch *before we've logged in*.
 add_task(async function test_momentary_401_storage_loggedout() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a failure for any storage URL before login, not just engine parts. " +
     "Resolved by reassignment.");
   let server = await prepareServer();
 
   // Return a 401 for all storage requests.
   let oldHandler = server.toplevelHandlers.storage;
   server.toplevelHandlers.storage = handleReassign;
--- a/services/sync/tests/unit/test_hmac_error.js
+++ b/services/sync/tests/unit/test_hmac_error.js
@@ -13,17 +13,17 @@ var hmacErrorCount = 0;
   let hHE = Service.handleHMACEvent;
   Service.handleHMACEvent = async function() {
     hmacErrorCount++;
     return hHE.call(Service);
   };
 })();
 
 async function shared_setup() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   hmacErrorCount = 0;
 
   // Make sure RotaryEngine is the only one we sync.
   let { engine, tracker } = await registerRotaryEngine();
   engine.lastSync = 123; // Needs to be non-zero so that tracker is queried.
   engine._store.items = {flying: "LNER Class A3 4472",
                          scotsman: "Flying Scotsman"};
--- a/services/sync/tests/unit/test_interval_triggers.js
+++ b/services/sync/tests/unit/test_interval_triggers.js
@@ -55,17 +55,17 @@ add_task(async function setup() {
 
   // Don't remove stale clients when syncing. This is a test-only workaround
   // that lets us add clients directly to the store, without losing them on
   // the next sync.
   clientsEngine._removeRemoteClient = async (id) => {};
 });
 
 add_task(async function test_successful_sync_adjustSyncInterval() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test successful sync calling adjustSyncInterval");
   let syncSuccesses = 0;
   function onSyncFinish() {
     _("Sync success.");
     syncSuccesses++;
   }
   Svc.Obs.add("weave:service:sync:finish", onSyncFinish);
@@ -156,17 +156,17 @@ add_task(async function test_successful_
   do_check_eq(scheduler.syncInterval, scheduler.immediateInterval);
 
   Svc.Obs.remove("weave:service:sync:finish", onSyncFinish);
   await Service.startOver();
   await promiseStopServer(server);
 });
 
 add_task(async function test_unsuccessful_sync_adjustSyncInterval() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test unsuccessful sync calling adjustSyncInterval");
 
   let syncFailures = 0;
   function onSyncError() {
     _("Sync error.");
     syncFailures++;
   }
@@ -264,17 +264,17 @@ add_task(async function test_unsuccessfu
   do_check_eq(scheduler.syncInterval, scheduler.immediateInterval);
 
   await Service.startOver();
   Svc.Obs.remove("weave:service:sync:error", onSyncError);
   await promiseStopServer(server);
 });
 
 add_task(async function test_back_triggers_sync() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = sync_httpd_setup();
   await setUp(server);
 
   // Single device: no sync triggered.
   scheduler.idle = true;
   scheduler.observe(null, "active", Svc.Prefs.get("scheduler.idleTime"));
   do_check_false(scheduler.idle);
@@ -295,17 +295,17 @@ add_task(async function test_back_trigge
   scheduler.setDefaults();
   await clientsEngine.resetClient();
 
   await Service.startOver();
   await promiseStopServer(server);
 });
 
 add_task(async function test_adjust_interval_on_sync_error() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = sync_httpd_setup();
   await setUp(server);
 
   let syncFailures = 0;
   function onSyncError() {
     _("Sync error.");
     syncFailures++;
@@ -328,17 +328,17 @@ add_task(async function test_adjust_inte
   do_check_eq(scheduler.syncInterval, scheduler.activeInterval);
 
   Svc.Obs.remove("weave:service:sync:error", onSyncError);
   await Service.startOver();
   await promiseStopServer(server);
 });
 
 add_task(async function test_bug671378_scenario() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test scenario similar to bug 671378. This bug appeared when a score
   // update occurred that wasn't large enough to trigger a sync so
   // scheduleNextSync() was called without a time interval parameter,
   // setting nextSync to a non-zero value and preventing the timer from
   // being adjusted in the next call to scheduleNextSync().
   let server = sync_httpd_setup();
   await setUp(server);
--- a/services/sync/tests/unit/test_node_reassignment.js
+++ b/services/sync/tests/unit/test_node_reassignment.js
@@ -130,17 +130,17 @@ async function syncAndExpectNodeReassign
 
   Svc.Obs.add(firstNotification, onFirstSync);
   await Service.sync();
 
   await deferred.promise;
 }
 
 add_task(async function test_momentary_401_engine() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a failure for engine URLs that's resolved by reassignment.");
   let server = await prepareServer();
   let john   = server.user("johndoe");
 
   _("Enabling the Rotary engine.");
   let { engine, tracker } = await registerRotaryEngine();
 
@@ -186,17 +186,17 @@ add_task(async function test_momentary_4
                                       Service.storageURL + "rotary");
 
   tracker.clearChangedIDs();
   Service.engineManager.unregister(engine);
 });
 
 // This test ends up being a failing fetch *after we're already logged in*.
 add_task(async function test_momentary_401_info_collections() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a failure for info/collections that's resolved by reassignment.");
   let server = await prepareServer();
 
   _("First sync to prepare server contents.");
   await Service.sync();
 
   // Return a 401 for info requests, particularly info/collections.
@@ -211,17 +211,17 @@ add_task(async function test_momentary_4
   await syncAndExpectNodeReassignment(server,
                                       "weave:service:sync:error",
                                       undo,
                                       "weave:service:sync:finish",
                                       Service.infoURL);
 });
 
 add_task(async function test_momentary_401_storage_loggedin() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a failure for any storage URL, not just engine parts. " +
     "Resolved by reassignment.");
   let server = await prepareServer();
 
   _("Performing initial sync to ensure we are logged in.");
   await Service.sync();
 
@@ -238,17 +238,17 @@ add_task(async function test_momentary_4
   await syncAndExpectNodeReassignment(server,
                                       "weave:service:sync:error",
                                       undo,
                                       "weave:service:sync:finish",
                                       Service.storageURL + "meta/global");
 });
 
 add_task(async function test_momentary_401_storage_loggedout() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a failure for any storage URL, not just engine parts. " +
     "Resolved by reassignment.");
   let server = await prepareServer();
 
   // Return a 401 for all storage requests.
   let oldHandler = server.toplevelHandlers.storage;
   server.toplevelHandlers.storage = handleReassign;
@@ -262,17 +262,17 @@ add_task(async function test_momentary_4
   await syncAndExpectNodeReassignment(server,
                                       "weave:service:login:error",
                                       undo,
                                       "weave:service:sync:finish",
                                       Service.storageURL + "meta/global");
 });
 
 add_task(async function test_loop_avoidance_storage() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test that a repeated failure doesn't result in a sync loop " +
     "if node reassignment cannot resolve the failure.");
 
   let server = await prepareServer();
 
   // Return a 401 for all storage requests.
   let oldHandler = server.toplevelHandlers.storage;
@@ -364,17 +364,17 @@ add_task(async function test_loop_avoida
   Svc.Obs.add(firstNotification, onFirstSync);
 
   now = Date.now();
   await Service.sync();
   await deferred.promise;
 });
 
 add_task(async function test_loop_avoidance_engine() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test that a repeated 401 in an engine doesn't result in a sync loop " +
     "if node reassignment cannot resolve the failure.");
   let server = await prepareServer();
   let john   = server.user("johndoe");
 
   _("Enabling the Rotary engine.");
   let { engine, tracker } = await registerRotaryEngine();
--- a/services/sync/tests/unit/test_password_engine.js
+++ b/services/sync/tests/unit/test_password_engine.js
@@ -25,17 +25,17 @@ async function cleanup(engine, server) {
 add_task(async function test_ignored_fields() {
   _("Only changes to syncable fields should be tracked");
 
   let engine = Service.engineManager.get("passwords");
 
   let server = await serverForFoo(engine);
   await SyncTestingInfrastructure(server);
 
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let login = Services.logins.addLogin(new LoginInfo("https://example.com", "",
     null, "username", "password", "", ""));
   login.QueryInterface(Ci.nsILoginMetaInfo); // For `guid`.
 
   Svc.Obs.notify("weave:engine:start-tracking");
 
   try {
@@ -62,17 +62,17 @@ add_task(async function test_ignored_fie
 add_task(async function test_ignored_sync_credentials() {
   _("Sync credentials in login manager should be ignored");
 
   let engine = Service.engineManager.get("passwords");
 
   let server = await serverForFoo(engine);
   await SyncTestingInfrastructure(server);
 
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   Svc.Obs.notify("weave:engine:start-tracking");
 
   try {
     let login = Services.logins.addLogin(new LoginInfo(FXA_PWDMGR_HOST, null,
       FXA_PWDMGR_REALM, "fxa-uid", "creds", "", ""));
 
     let noChanges = await engine.pullNewChanges();
@@ -93,17 +93,17 @@ add_task(async function test_password_en
   _("Basic password sync test");
 
   let engine = Service.engineManager.get("passwords");
 
   let server = await serverForFoo(engine);
   await SyncTestingInfrastructure(server);
   let collection = server.user("foo").collection("passwords");
 
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Add new login to upload during first sync");
   let newLogin;
   {
     let login = new LoginInfo("https://example.com", "", null, "username",
       "password", "", "");
     Services.logins.addLogin(login);
 
deleted file mode 100644
--- a/services/sync/tests/unit/test_password_validator.js
+++ /dev/null
@@ -1,147 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
-   http://creativecommons.org/publicdomain/zero/1.0/ */
-
-Components.utils.import("resource://services-sync/engines/passwords.js");
-
-function getDummyServerAndClient() {
-  return {
-    server: [
-      {
-        id: "11111",
-        guid: "11111",
-        hostname: "https://www.11111.com",
-        formSubmitURL: "https://www.11111.com/login",
-        password: "qwerty123",
-        passwordField: "pass",
-        username: "foobar",
-        usernameField: "user",
-        httpRealm: null,
-      },
-      {
-        id: "22222",
-        guid: "22222",
-        hostname: "https://www.22222.org",
-        formSubmitURL: "https://www.22222.org/login",
-        password: "hunter2",
-        passwordField: "passwd",
-        username: "baz12345",
-        usernameField: "user",
-        httpRealm: null,
-      },
-      {
-        id: "33333",
-        guid: "33333",
-        hostname: "https://www.33333.com",
-        formSubmitURL: "https://www.33333.com/login",
-        password: "p4ssw0rd",
-        passwordField: "passwad",
-        username: "quux",
-        usernameField: "user",
-        httpRealm: null,
-      },
-    ],
-    client: [
-      {
-        id: "11111",
-        guid: "11111",
-        hostname: "https://www.11111.com",
-        formSubmitURL: "https://www.11111.com/login",
-        password: "qwerty123",
-        passwordField: "pass",
-        username: "foobar",
-        usernameField: "user",
-        httpRealm: null,
-      },
-      {
-        id: "22222",
-        guid: "22222",
-        hostname: "https://www.22222.org",
-        formSubmitURL: "https://www.22222.org/login",
-        password: "hunter2",
-        passwordField: "passwd",
-        username: "baz12345",
-        usernameField: "user",
-        httpRealm: null,
-
-      },
-      {
-        id: "33333",
-        guid: "33333",
-        hostname: "https://www.33333.com",
-        formSubmitURL: "https://www.33333.com/login",
-        password: "p4ssw0rd",
-        passwordField: "passwad",
-        username: "quux",
-        usernameField: "user",
-        httpRealm: null,
-      }
-    ]
-  };
-}
-
-
-add_task(async function test_valid() {
-  let { server, client } = getDummyServerAndClient();
-  let validator = new PasswordValidator();
-  let { problemData, clientRecords, records, deletedRecords } =
-      await validator.compareClientWithServer(client, server);
-  equal(clientRecords.length, 3);
-  equal(records.length, 3);
-  equal(deletedRecords.length, 0);
-  deepEqual(problemData, validator.emptyProblemData());
-});
-
-add_task(async function test_missing() {
-  let validator = new PasswordValidator();
-  {
-    let { server, client } = getDummyServerAndClient();
-
-    client.pop();
-
-    let { problemData, clientRecords, records, deletedRecords } =
-        await validator.compareClientWithServer(client, server);
-
-    equal(clientRecords.length, 2);
-    equal(records.length, 3);
-    equal(deletedRecords.length, 0);
-
-    let expected = validator.emptyProblemData();
-    expected.clientMissing.push("33333");
-    deepEqual(problemData, expected);
-  }
-  {
-    let { server, client } = getDummyServerAndClient();
-
-    server.pop();
-
-    let { problemData, clientRecords, records, deletedRecords } =
-        await validator.compareClientWithServer(client, server);
-
-    equal(clientRecords.length, 3);
-    equal(records.length, 2);
-    equal(deletedRecords.length, 0);
-
-    let expected = validator.emptyProblemData();
-    expected.serverMissing.push("33333");
-    deepEqual(problemData, expected);
-  }
-});
-
-
-add_task(async function test_deleted() {
-  let { server, client } = getDummyServerAndClient();
-  let deletionRecord = { id: "444444", guid: "444444", deleted: true };
-
-  server.push(deletionRecord);
-  let validator = new PasswordValidator();
-
-  let { problemData, clientRecords, records, deletedRecords } =
-      await validator.compareClientWithServer(client, server);
-
-  equal(clientRecords.length, 3);
-  equal(records.length, 4);
-  deepEqual(deletedRecords, [deletionRecord]);
-
-  let expected = validator.emptyProblemData();
-  deepEqual(problemData, expected);
-});
--- a/services/sync/tests/unit/test_score_triggers.js
+++ b/services/sync/tests/unit/test_score_triggers.js
@@ -47,17 +47,17 @@ function run_test() {
   initTestLogging("Trace");
 
   Log.repository.getLogger("Sync.Service").level = Log.Level.Trace;
 
   run_next_test();
 }
 
 add_task(async function test_tracker_score_updated() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
   let { engine, tracker } = await registerRotaryEngine();
 
   let scoreUpdated = 0;
 
   function onScoreUpdated() {
     scoreUpdated++;
   }
 
@@ -95,17 +95,17 @@ add_task(async function test_sync_trigge
   await Service.startOver();
   await promiseStopServer(server);
 
   tracker.clearChangedIDs();
   Service.engineManager.unregister(engine);
 });
 
 add_task(async function test_clients_engine_sync_triggered() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Ensure that client engine score changes trigger a sync.");
 
   // The clients engine is not registered like other engines. Therefore,
   // it needs special treatment throughout the code. Here, we verify the
   // global score tracker gives it that treatment. See bug 676042 for more.
 
   let server = sync_httpd_setup();
@@ -122,17 +122,17 @@ add_task(async function test_clients_eng
   await Service.startOver();
   await promiseStopServer(server);
 
   tracker.clearChangedIDs();
   Service.engineManager.unregister(engine);
 });
 
 add_task(async function test_incorrect_credentials_sync_not_triggered() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Ensure that score changes don't trigger a sync if Status.login != LOGIN_SUCCEEDED.");
   let server = sync_httpd_setup();
   let { engine, tracker } = await setUp(server);
 
   // Ensure we don't actually try to sync.
   function onSyncStart() {
     do_throw("Should not get here!");
--- a/services/sync/tests/unit/test_service_detect_upgrade.js
+++ b/services/sync/tests/unit/test_service_detect_upgrade.js
@@ -7,17 +7,17 @@ Cu.import("resource://services-sync/keys
 Cu.import("resource://services-sync/engines/tabs.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/service.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://testing-common/services/sync/utils.js");
 
 add_task(async function v4_upgrade() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let clients = new ServerCollection();
   let meta_global = new ServerWBO("global");
 
   // Tracking info/collections.
   let collectionsHelper = track_collections_helper();
   let upd = collectionsHelper.with_updated_collection;
   let collections = collectionsHelper.collections;
@@ -164,17 +164,17 @@ add_task(async function v4_upgrade() {
 
   } finally {
     Svc.Prefs.resetBranch("");
     await promiseStopServer(server);
   }
 });
 
 add_task(async function v5_upgrade() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Tracking info/collections.
   let collectionsHelper = track_collections_helper();
   let upd = collectionsHelper.with_updated_collection;
 
   let keysWBO = new ServerWBO("keys");
   let bulkWBO = new ServerWBO("bulk");
   let clients = new ServerCollection();
--- a/services/sync/tests/unit/test_service_login.js
+++ b/services/sync/tests/unit/test_service_login.js
@@ -65,17 +65,17 @@ add_task(async function test_not_logged_
     do_check_eq(Service._checkSync(), kSyncNotConfigured);
   } finally {
     Svc.Prefs.resetBranch("");
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_login_logout() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = setup();
 
   try {
     _("Force the initial state.");
     Service.status.service = STATUS_OK;
     do_check_eq(Service.status.service, STATUS_OK);
 
@@ -113,17 +113,17 @@ add_task(async function test_login_logou
 
   } finally {
     Svc.Prefs.resetBranch("");
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_login_on_sync() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = setup();
   await configureIdentity({ username: "johndoe" }, server);
 
   try {
     _("Sync calls login.");
     let oldLogin = Service.login;
     let loginCalled = false;
--- a/services/sync/tests/unit/test_service_sync_remoteSetup.js
+++ b/services/sync/tests/unit/test_service_sync_remoteSetup.js
@@ -5,17 +5,17 @@ Cu.import("resource://gre/modules/Log.js
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/keys.js");
 Cu.import("resource://services-sync/service.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://testing-common/services/sync/fakeservices.js");
 Cu.import("resource://testing-common/services/sync/utils.js");
 
 add_task(async function run_test() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   validate_all_future_pings();
   Log.repository.rootLogger.addAppender(new Log.DumpAppender());
 
   let clients = new ServerCollection();
   let meta_global = new ServerWBO("global");
 
   let collectionsHelper = track_collections_helper();
--- a/services/sync/tests/unit/test_service_sync_specified.js
+++ b/services/sync/tests/unit/test_service_sync_specified.js
@@ -78,34 +78,34 @@ add_task(async function setup() {
   Log.repository.getLogger("Sync.Service").level = Log.Level.Trace;
   Log.repository.getLogger("Sync.ErrorHandler").level = Log.Level.Trace;
 
   await Service.engineManager.register(SteamEngine);
   await Service.engineManager.register(StirlingEngine);
 });
 
 add_task(async function test_noEngines() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: An empty array of engines to sync does nothing.");
   let server = await setUp();
 
   try {
     _("Sync with no engines specified.");
     await Service.sync([]);
     deepEqual(syncedEngines, [], "no engines were synced");
 
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_oneEngine() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Only one engine is synced.");
   let server = await setUp();
 
   try {
 
     _("Sync with 1 engine specified.");
     await Service.sync(["steam"]);
@@ -113,51 +113,51 @@ add_task(async function test_oneEngine()
 
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_bothEnginesSpecified() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: All engines are synced when specified in the correct order (1).");
   let server = await setUp();
 
   try {
     _("Sync with both engines specified.");
     await Service.sync(["steam", "stirling"]);
     deepEqual(syncedEngines, ["steam", "stirling"]);
 
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_bothEnginesSpecified() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: All engines are synced when specified in the correct order (2).");
   let server = await setUp();
 
   try {
     _("Sync with both engines specified.");
     await Service.sync(["stirling", "steam"]);
     deepEqual(syncedEngines, ["stirling", "steam"]);
 
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_bothEnginesDefault() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: All engines are synced when nothing is specified.");
   let server = await setUp();
 
   try {
     await Service.sync();
     deepEqual(syncedEngines, ["steam", "stirling"]);
 
--- a/services/sync/tests/unit/test_service_sync_updateEnabledEngines.js
+++ b/services/sync/tests/unit/test_service_sync_updateEnabledEngines.js
@@ -84,17 +84,17 @@ add_task(async function setup() {
   Log.repository.getLogger("Sync.ErrorHandler").level = Log.Level.Trace;
   validate_all_future_pings();
 
   await Service.engineManager.register(SteamEngine);
   await Service.engineManager.register(StirlingEngine);
 });
 
 add_task(async function test_newAccount() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: New account does not disable locally enabled engines.");
   let engine = Service.engineManager.get("steam");
   let server = sync_httpd_setup({
     "/1.1/johndoe/storage/meta/global": new ServerWBO("global", {}).handler(),
     "/1.1/johndoe/storage/steam": new ServerWBO("steam", {}).handler()
   });
   await setUp(server);
@@ -112,17 +112,17 @@ add_task(async function test_newAccount(
     do_check_true(engine.enabled);
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_enabledLocally() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Engine is disabled on remote clients and enabled locally");
   Service.syncID = "abcdefghij";
   let engine = Service.engineManager.get("steam");
   let metaWBO = new ServerWBO("global", {syncID: Service.syncID,
                                          storageVersion: STORAGE_VERSION,
                                          engines: {}});
   let server = sync_httpd_setup({
@@ -145,17 +145,17 @@ add_task(async function test_enabledLoca
     do_check_true(engine.enabled);
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_disabledLocally() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Engine is enabled on remote clients and disabled locally");
   Service.syncID = "abcdefghij";
   let engine = Service.engineManager.get("steam");
   let metaWBO = new ServerWBO("global", {
     syncID: Service.syncID,
     storageVersion: STORAGE_VERSION,
     engines: {steam: {syncID: engine.syncID,
@@ -189,17 +189,17 @@ add_task(async function test_disabledLoc
     do_check_false(engine.enabled);
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_disabledLocally_wipe503() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Engine is enabled on remote clients and disabled locally");
   Service.syncID = "abcdefghij";
   let engine = Service.engineManager.get("steam");
   let metaWBO = new ServerWBO("global", {
     syncID: Service.syncID,
     storageVersion: STORAGE_VERSION,
     engines: {steam: {syncID: engine.syncID,
@@ -232,17 +232,17 @@ add_task(async function test_disabledLoc
   await promiseObserved;
   do_check_eq(Service.status.sync, SERVER_MAINTENANCE);
 
   await Service.startOver();
   await promiseStopServer(server);
 });
 
 add_task(async function test_enabledRemotely() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Engine is disabled locally and enabled on a remote client");
   Service.syncID = "abcdefghij";
   let engine = Service.engineManager.get("steam");
   let metaWBO = new ServerWBO("global", {
     syncID: Service.syncID,
     storageVersion: STORAGE_VERSION,
     engines: {steam: {syncID: engine.syncID,
@@ -278,17 +278,17 @@ add_task(async function test_enabledRemo
     do_check_eq(metaWBO.data.engines.steam.syncID, engine.syncID);
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_disabledRemotelyTwoClients() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Engine is enabled locally and disabled on a remote client... with two clients.");
   Service.syncID = "abcdefghij";
   let engine = Service.engineManager.get("steam");
   let metaWBO = new ServerWBO("global", {syncID: Service.syncID,
                                          storageVersion: STORAGE_VERSION,
                                          engines: {}});
   let server = sync_httpd_setup({
@@ -324,17 +324,17 @@ add_task(async function test_disabledRem
 
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_disabledRemotely() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Engine is enabled locally and disabled on a remote client");
   Service.syncID = "abcdefghij";
   let engine = Service.engineManager.get("steam");
   let metaWBO = new ServerWBO("global", {syncID: Service.syncID,
                                          storageVersion: STORAGE_VERSION,
                                          engines: {}});
   let server = sync_httpd_setup({
@@ -357,17 +357,17 @@ add_task(async function test_disabledRem
 
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_dependentEnginesEnabledLocally() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Engine is disabled on remote clients and enabled locally");
   Service.syncID = "abcdefghij";
   let steamEngine = Service.engineManager.get("steam");
   let stirlingEngine = Service.engineManager.get("stirling");
   let metaWBO = new ServerWBO("global", {syncID: Service.syncID,
                                          storageVersion: STORAGE_VERSION,
                                          engines: {}});
@@ -394,17 +394,17 @@ add_task(async function test_dependentEn
     do_check_true(stirlingEngine.enabled);
   } finally {
     await Service.startOver();
     await promiseStopServer(server);
   }
 });
 
 add_task(async function test_dependentEnginesDisabledLocally() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test: Two dependent engines are enabled on remote clients and disabled locally");
   Service.syncID = "abcdefghij";
   let steamEngine = Service.engineManager.get("steam");
   let stirlingEngine = Service.engineManager.get("stirling");
   let metaWBO = new ServerWBO("global", {
     syncID: Service.syncID,
     storageVersion: STORAGE_VERSION,
--- a/services/sync/tests/unit/test_syncscheduler.js
+++ b/services/sync/tests/unit/test_syncscheduler.js
@@ -186,17 +186,17 @@ add_task(async function test_updateClien
   do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval);
   do_check_false(scheduler.numClients > 1);
   do_check_false(scheduler.idle);
 
   await cleanUpAndGo();
 });
 
 add_task(async function test_masterpassword_locked_retry_interval() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test Status.login = MASTER_PASSWORD_LOCKED results in reschedule at MASTER_PASSWORD interval");
   let loginFailed = false;
   Svc.Obs.add("weave:service:login:error", function onLoginError() {
     Svc.Obs.remove("weave:service:login:error", onLoginError);
     loginFailed = true;
   });
 
@@ -247,31 +247,31 @@ add_task(async function test_calculateBa
                                            Status.backoffInterval);
 
   do_check_eq(backoffInterval, MAXIMUM_BACKOFF_INTERVAL + 10);
 
   await cleanUpAndGo();
 });
 
 add_task(async function test_scheduleNextSync_nowOrPast() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let promiseObserved = promiseOneObserver("weave:service:sync:finish");
 
   let server = sync_httpd_setup();
   await setUp(server);
 
   // We're late for a sync...
   scheduler.scheduleNextSync(-1);
   await promiseObserved;
   await cleanUpAndGo(server);
 });
 
 add_task(async function test_scheduleNextSync_future_noBackoff() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("scheduleNextSync() uses the current syncInterval if no interval is provided.");
   // Test backoffInterval is 0 as expected.
   do_check_eq(Status.backoffInterval, 0);
 
   _("Test setting sync interval when nextSync == 0");
   scheduler.nextSync = 0;
   scheduler.scheduleNextSync();
@@ -312,17 +312,17 @@ add_task(async function test_scheduleNex
   scheduler.scheduleNextSync(1);
   do_check_true(scheduler.nextSync <= Date.now() + 1);
   do_check_eq(scheduler.syncTimer.delay, 1);
 
   await cleanUpAndGo();
 });
 
 add_task(async function test_scheduleNextSync_future_backoff() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
  _("scheduleNextSync() will honour backoff in all scheduling requests.");
   // Let's take a backoff interval that's bigger than the default sync interval.
   const BACKOFF = 7337;
   Status.backoffInterval = scheduler.syncInterval + BACKOFF;
 
   _("Test setting sync interval when nextSync == 0");
   scheduler.nextSync = 0;
@@ -364,17 +364,17 @@ add_task(async function test_scheduleNex
   scheduler.scheduleNextSync(1);
   do_check_true(scheduler.nextSync <= Date.now() + Status.backoffInterval);
   do_check_eq(scheduler.syncTimer.delay, Status.backoffInterval);
 
   await cleanUpAndGo();
 });
 
 add_task(async function test_handleSyncError() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = sync_httpd_setup();
   await setUp(server);
 
   // Force sync to fail.
   Svc.Prefs.set("firstSync", "notReady");
 
   _("Ensure expected initial environment.");
@@ -429,17 +429,17 @@ add_task(async function test_handleSyncE
   let promiseObserved = promiseOneObserver("weave:service:sync:finish");
   Svc.Prefs.set("firstSync", "wipeRemote");
   scheduler.scheduleNextSync(-1);
   await promiseObserved;
   await cleanUpAndGo(server);
 });
 
 add_task(async function test_client_sync_finish_updateClientMode() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = sync_httpd_setup();
   await setUp(server);
 
   // Confirm defaults.
   do_check_eq(scheduler.syncThreshold, SINGLE_USER_THRESHOLD);
   do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval);
   do_check_false(scheduler.idle);
@@ -470,31 +470,31 @@ add_task(async function test_client_sync
   do_check_eq(scheduler.syncInterval, scheduler.singleDeviceInterval);
   do_check_false(scheduler.numClients > 1);
   do_check_false(scheduler.idle);
 
   await cleanUpAndGo(server);
 });
 
 add_task(async function test_autoconnect_nextSync_past() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let promiseObserved = promiseOneObserver("weave:service:sync:finish");
   // nextSync will be 0 by default, so it's way in the past.
 
   let server = sync_httpd_setup();
   await setUp(server);
 
   scheduler.delayedAutoConnect(0);
   await promiseObserved;
   await cleanUpAndGo(server);
 });
 
 add_task(async function test_autoconnect_nextSync_future() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let previousSync = Date.now() + scheduler.syncInterval / 2;
   scheduler.nextSync = previousSync;
   // nextSync rounds to the nearest second.
   let expectedSync = scheduler.nextSync;
   let expectedInterval = expectedSync - Date.now() - 1000;
 
   // Ensure we don't actually try to sync (or log in for that matter).
@@ -584,17 +584,17 @@ add_task(async function test_no_autoconn
 
   do_check_eq(Status.service, CLIENT_NOT_CONFIGURED);
   do_check_eq(Status.login, LOGIN_FAILED_NO_USERNAME);
 
   await cleanUpAndGo(server);
 });
 
 add_task(async function test_autoconnectDelay_pref() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let promiseObserved = promiseOneObserver("weave:service:sync:finish");
 
   Svc.Prefs.set("autoconnectDelay", 1);
 
   let server = sync_httpd_setup();
   await setUp(server);
 
@@ -701,17 +701,17 @@ add_task(async function test_back_deboun
   scheduler.observe(null, "idle", Svc.Prefs.get("scheduler.idleTime"));
 
   await promiseNamedTimer(IDLE_OBSERVER_BACK_DELAY * 1.5, {}, "timer");
   Svc.Obs.remove("weave:service:login:start", onLoginStart);
   await cleanUpAndGo();
 });
 
 add_task(async function test_no_sync_node() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   // Test when Status.sync == NO_SYNC_NODE_FOUND
   // it is not overwritten on sync:finish
   let server = sync_httpd_setup();
   await setUp(server);
 
   let oldfc = Service._clusterManager._findCluster;
   Service._clusterManager._findCluster = () => null;
@@ -723,17 +723,17 @@ add_task(async function test_no_sync_nod
 
     await cleanUpAndGo(server);
   } finally {
     Service._clusterManager._findCluster = oldfc;
   }
 });
 
 add_task(async function test_sync_failed_partial_500s() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a 5xx status calls handleSyncError.");
   scheduler._syncErrors = MAX_ERROR_COUNT_BEFORE_BACKOFF;
   let server = sync_httpd_setup();
 
   let engine = Service.engineManager.get("catapult");
   engine.enabled = true;
   engine.exception = {status: 500};
@@ -783,17 +783,17 @@ add_task(async function test_sync_failed
   await resyncDoneObserver;
 
   Svc.Obs.remove("weave:service:sync:start", onSyncStarted);
   engine._tracker._store = 0;
   await cleanUpAndGo(server);
 });
 
 add_task(async function test_sync_failed_partial_400s() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   _("Test a non-5xx status doesn't call handleSyncError.");
   scheduler._syncErrors = MAX_ERROR_COUNT_BEFORE_BACKOFF;
   let server = sync_httpd_setup();
 
   let engine = Service.engineManager.get("catapult");
   engine.enabled = true;
   engine.exception = {status: 400};
@@ -817,17 +817,17 @@ add_task(async function test_sync_failed
   do_check_eq(scheduler._syncErrors, 0);
   do_check_true(scheduler.nextSync <= (Date.now() + scheduler.activeInterval));
   do_check_true(scheduler.syncTimer.delay <= scheduler.activeInterval);
 
   await cleanUpAndGo(server);
 });
 
 add_task(async function test_sync_X_Weave_Backoff() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = sync_httpd_setup();
   await setUp(server);
 
   // Use an odd value on purpose so that it doesn't happen to coincide with one
   // of the sync intervals.
   const BACKOFF = 7337;
 
@@ -876,17 +876,17 @@ add_task(async function test_sync_X_Weav
   // Verify that the next sync is actually going to wait that long.
   do_check_true(scheduler.nextSync >= Date.now() + minimumExpectedDelay);
   do_check_true(scheduler.syncTimer.delay >= minimumExpectedDelay);
 
   await cleanUpAndGo(server);
 });
 
 add_task(async function test_sync_503_Retry_After() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let server = sync_httpd_setup();
   await setUp(server);
 
   // Use an odd value on purpose so that it doesn't happen to coincide with one
   // of the sync intervals.
   const BACKOFF = 7337;
 
--- a/services/sync/tests/unit/test_telemetry.js
+++ b/services/sync/tests/unit/test_telemetry.js
@@ -12,16 +12,17 @@ Cu.import("resource://services-sync/engi
 Cu.import("resource://services-sync/engines/bookmarks.js");
 Cu.import("resource://services-sync/engines/clients.js");
 Cu.import("resource://testing-common/services/sync/utils.js");
 Cu.import("resource://testing-common/services/sync/fxa_utils.js");
 Cu.import("resource://testing-common/services/sync/rotaryengine.js");
 Cu.import("resource://gre/modules/osfile.jsm", this);
 
 Cu.import("resource://services-sync/util.js");
+Cu.import("resource://sync-repair/bookmark_validator.jsm");
 
 
 function SteamStore(engine) {
   Store.call(this, "Steam", engine);
 }
 
 SteamStore.prototype = {
   __proto__: Store.prototype,
@@ -68,17 +69,17 @@ async function cleanAndGo(engine, server
 
 add_task(async function setup() {
   initTestLogging("Trace");
   // Avoid addon manager complaining about not being initialized
   Service.engineManager.unregister("addons");
 });
 
 add_task(async function test_basic() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let helper = track_collections_helper();
   let upd = helper.with_updated_collection;
 
   let handlers = {
     "/1.1/johndoe/info/collections": helper.handler,
     "/1.1/johndoe/storage/crypto/keys": upd("crypto", new ServerWBO("keys").handler()),
     "/1.1/johndoe/storage/meta/global": upd("meta", new ServerWBO("global").handler())
@@ -333,17 +334,17 @@ add_task(async function test_sync_partia
 
   } finally {
     await cleanAndGo(engine, server);
     await engine.finalize();
   }
 });
 
 add_task(async function test_generic_engine_fail() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   await Service.engineManager.register(SteamEngine);
   let engine = Service.engineManager.get("steam");
   engine.enabled = true;
   let server = await serverForFoo(engine);
   await SyncTestingInfrastructure(server);
   let e = new Error("generic failure message");
   engine._errToThrow = e;
@@ -359,17 +360,17 @@ add_task(async function test_generic_eng
     });
   } finally {
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
 
 add_task(async function test_engine_fail_weird_errors() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
   await Service.engineManager.register(SteamEngine);
   let engine = Service.engineManager.get("steam");
   engine.enabled = true;
   let server = await serverForFoo(engine);
   await SyncTestingInfrastructure(server);
   try {
     let msg = "Bad things happened!";
     engine._errToThrow = { message: msg };
@@ -389,17 +390,17 @@ add_task(async function test_engine_fail
 
   } finally {
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
 
 add_task(async function test_engine_fail_ioerror() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   await Service.engineManager.register(SteamEngine);
   let engine = Service.engineManager.get("steam");
   engine.enabled = true;
   let server = await serverForFoo(engine);
   await SyncTestingInfrastructure(server);
   // create an IOError to re-throw as part of Sync.
   try {
@@ -424,17 +425,17 @@ add_task(async function test_engine_fail
     ok(failureReason.error.includes("[profileDir]"), failureReason.error);
   } finally {
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
 
 add_task(async function test_clean_urls() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   Service.engineManager.register(SteamEngine);
   let engine = Service.engineManager.get("steam");
   engine.enabled = true;
   let server = await serverForFoo(engine);
   await SyncTestingInfrastructure(server);
   engine._errToThrow = new TypeError("http://www.google .com is not a valid URL.");
 
@@ -456,17 +457,17 @@ add_task(async function test_clean_urls(
   } finally {
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
 
 
 add_task(async function test_initial_sync_engines() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   await Service.engineManager.register(SteamEngine);
   let engine = Service.engineManager.get("steam");
   engine.enabled = true;
   // These are the only ones who actually have things to sync at startup.
   let engineNames = ["clients", "bookmarks", "prefs", "tabs"];
   let server = await serverForEnginesWithKeys({"foo": "password"}, ["bookmarks", "prefs", "tabs"].map(name =>
     Service.engineManager.get(name)
@@ -493,17 +494,17 @@ add_task(async function test_initial_syn
     }
   } finally {
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
 
 add_task(async function test_nserror() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   await Service.engineManager.register(SteamEngine);
   let engine = Service.engineManager.get("steam");
   engine.enabled = true;
   let server = await serverForFoo(engine);
   await SyncTestingInfrastructure(server);
   engine._errToThrow = Components.Exception("NS_ERROR_UNKNOWN_HOST", Cr.NS_ERROR_UNKNOWN_HOST);
   try {
@@ -521,17 +522,17 @@ add_task(async function test_nserror() {
     });
   } finally {
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
 
 add_task(async function test_discarding() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let helper = track_collections_helper();
   let upd = helper.with_updated_collection;
   let telem = get_sync_test_telemetry();
   telem.maxPayloadCount = 2;
   telem.submissionInterval = Infinity;
   let oldSubmit = telem.submit;
 
@@ -568,17 +569,17 @@ add_task(async function test_discarding(
     telem.submit = oldSubmit;
     if (server) {
       await promiseStopServer(server);
     }
   }
 });
 
 add_task(async function test_no_foreign_engines_in_error_ping() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   await Service.engineManager.register(BogusEngine);
   let engine = Service.engineManager.get("bogus");
   engine.enabled = true;
   let server = await serverForFoo(engine);
   engine._errToThrow = new Error("Oh no!");
   await SyncTestingInfrastructure(server);
   try {
@@ -587,17 +588,17 @@ add_task(async function test_no_foreign_
     ok(ping.engines.every(e => e.name !== "bogus"));
   } finally {
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
 
 add_task(async function test_no_foreign_engines_in_success_ping() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   await Service.engineManager.register(BogusEngine);
   let engine = Service.engineManager.get("bogus");
   engine.enabled = true;
   let server = await serverForFoo(engine);
 
   await SyncTestingInfrastructure(server);
   try {
@@ -605,17 +606,17 @@ add_task(async function test_no_foreign_
     ok(ping.engines.every(e => e.name !== "bogus"));
   } finally {
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
 
 add_task(async function test_events() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   await Service.engineManager.register(BogusEngine);
   let engine = Service.engineManager.get("bogus");
   engine.enabled = true;
   let server = await serverForFoo(engine);
 
   await SyncTestingInfrastructure(server);
   try {
@@ -649,17 +650,17 @@ add_task(async function test_events() {
     equal(value, null);
   } finally {
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
 
 add_task(async function test_invalid_events() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   await Service.engineManager.register(BogusEngine);
   let engine = Service.engineManager.get("bogus");
   engine.enabled = true;
   let server = await serverForFoo(engine);
 
   async function checkNotRecorded(...args) {
     Service.recordTelemetryEvent.call(args);
@@ -692,17 +693,17 @@ add_task(async function test_invalid_eve
     await checkNotRecorded("object", "method", "value", badextra);
   } finally {
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
 
 add_task(async function test_no_ping_for_self_hosters() {
-  enableValidationPrefs();
+  forceBookmarkValidation();
 
   let telem = get_sync_test_telemetry();
   let oldSubmit = telem.submit;
 
   await Service.engineManager.register(BogusEngine);
   let engine = Service.engineManager.get("bogus");
   engine.enabled = true;
   let server = await serverForFoo(engine);
@@ -721,8 +722,118 @@ add_task(async function test_no_ping_for
     // so we don't need to do anything to simulate a self-hosted user.
     ok(!pingSubmitted, "Should not submit ping with custom token server URL");
   } finally {
     telem.submit = oldSubmit;
     await cleanAndGo(engine, server);
     Service.engineManager.unregister(engine);
   }
 });
+
+async function validationPing(server, client, duration) {
+  let pingPromise = wait_for_ping(() => {}, true); // Allow "failing" pings, since having validation info indicates failure.
+  // fake this entirely
+  Svc.Obs.notify("weave:service:sync:start");
+  Svc.Obs.notify("weave:engine:sync:start", null, "bookmarks");
+  Svc.Obs.notify("weave:engine:sync:finish", null, "bookmarks");
+  let validator = new BookmarkValidator();
+  let {problemData} = await validator.compareServerWithClient(server, client);
+  let data = {
+    // We fake duration and version just so that we can verify they"re passed through.
+    duration,
+    version: validator.version,
+    recordCount: server.length,
+    problems: problemData,
+  };
+  Svc.Obs.notify("weave:engine:validate:finish", data, "bookmarks");
+  Svc.Obs.notify("weave:service:sync:finish");
+  return pingPromise;
+}
+
+add_task(async function test_telemetry_integration() {
+  // It would be nice not to need to copy these from the repair tests...
+  // But this test can't go there since it needs access to the ping schema,
+  // which isn't un-solvable, but this seemed easier and more worthwhile
+  let server = [
+    {
+      id: "menu",
+      parentid: "places",
+      type: "folder",
+      parentName: "",
+      title: "foo",
+      children: ["bbbbbbbbbbbb", "cccccccccccc"]
+    },
+    {
+      id: "bbbbbbbbbbbb",
+      type: "bookmark",
+      parentid: "menu",
+      parentName: "foo",
+      title: "bar",
+      bmkUri: "http://baz.com"
+    },
+    {
+      id: "cccccccccccc",
+      parentid: "menu",
+      parentName: "foo",
+      title: "",
+      type: "query",
+      bmkUri: "place:type=6&sort=14&maxResults=10"
+    }
+  ];
+
+  let client = {
+    "guid": "root________",
+    "title": "",
+    "id": 1,
+    "type": "text/x-moz-place-container",
+    "children": [
+      {
+        "guid": "menu________",
+        "title": "foo",
+        "id": 1000,
+        "type": "text/x-moz-place-container",
+        "children": [
+          {
+            "guid": "bbbbbbbbbbbb",
+            "title": "bar",
+            "id": 1001,
+            "type": "text/x-moz-place",
+            "uri": "http://baz.com"
+          },
+          {
+            "guid": "cccccccccccc",
+            "title": "",
+            "id": 1002,
+            "annos": [{
+              "name": "Places/SmartBookmark",
+              "flags": 0,
+              "expires": 4,
+              "value": "RecentTags"
+            }],
+            "type": "text/x-moz-place",
+            "uri": "place:type=6&sort=14&maxResults=10"
+          }
+        ]
+      }
+    ]
+  };
+
+  // remove "c"
+  server.pop();
+  server[0].children.pop();
+  const duration = 50;
+  let ping = await validationPing(server, client, duration);
+  ok(ping.engines);
+  let bme = ping.engines.find(e => e.name === "bookmarks");
+  ok(bme);
+  ok(bme.validation);
+  ok(bme.validation.problems);
+  equal(bme.validation.checked, server.length);
+  equal(bme.validation.took, duration);
+  bme.validation.problems.sort((a, b) => String(a.name).localeCompare(b.name));
+  equal(bme.validation.version, new BookmarkValidator().version);
+  deepEqual(bme.validation.problems, [
+    { name: "badClientRoots", count: 3 },
+    { name: "sdiff:childGUIDs", count: 1 },
+    { name: "serverMissing", count: 1 },
+    { name: "structuralDifferences", count: 1 },
+  ]);
+});
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -1,10 +1,10 @@
 [DEFAULT]
-head = head_appinfo.js ../../../common/tests/unit/head_helpers.js head_helpers.js head_http_server.js head_errorhandler_common.js
+head = head_appinfo.js ../../../common/tests/unit/head_helpers.js head_helpers.js head_http_server.js head_errorhandler_common.js ../../../../browser/extensions/sync-repair/test/unit/head.js
 firefox-appdir = browser
 skip-if = asan # asan crashes, bug 1344085
 support-files =
   addon1-search.xml
   bootstrap1-search.xml
   missing-sourceuri.xml
   missing-xpi-search.xml
   places_v10_from_v11.sqlite
@@ -135,53 +135,40 @@ tags = addons
 [test_bookmark_duping.js]
 run-sequentially = Frequent timeouts, bug 1395148
 [test_bookmark_engine.js]
 [test_bookmark_invalid.js]
 [test_bookmark_livemarks.js]
 [test_bookmark_order.js]
 [test_bookmark_places_query_rewriting.js]
 [test_bookmark_record.js]
-[test_bookmark_repair.js]
-skip-if = release_or_beta
-run-sequentially = Frequent timeouts, bug 1395148
-[test_bookmark_repair_requestor.js]
-# Repair is enabled only on Aurora and Nightly
-skip-if = release_or_beta
-[test_bookmark_repair_responder.js]
-skip-if = release_or_beta
-run-sequentially = Frequent timeouts, bug 1395148
 [test_bookmark_smart_bookmarks.js]
 [test_bookmark_store.js]
 [test_bookmark_decline_undecline.js]
 # Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479)
 skip-if = debug
 [test_bookmark_tracker.js]
 requesttimeoutfactor = 4
-[test_bookmark_validator.js]
 [test_clients_engine.js]
 run-sequentially = Frequent timeouts, bug 1395148
 [test_clients_escape.js]
-[test_doctor.js]
 [test_extension_storage_engine.js]
 [test_extension_storage_tracker.js]
 [test_forms_store.js]
 [test_forms_tracker.js]
 # Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479)
 skip-if = debug
-[test_form_validator.js]
 [test_history_engine.js]
 [test_history_store.js]
 [test_history_tracker.js]
 # Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479)
 skip-if = debug
 [test_places_guid_downgrade.js]
 [test_password_engine.js]
 [test_password_store.js]
-[test_password_validator.js]
 [test_password_tracker.js]
 # Too many intermittent "ASSERTION: thread pool wasn't shutdown: '!mPool'" (bug 804479)
 skip-if = debug
 [test_prefs_store.js]
 support-files = prefs_test_prefs_store.js
 [test_prefs_tracker.js]
 [test_tab_engine.js]
 [test_tab_store.js]
--- a/services/sync/tps/extensions/tps/resource/tps.jsm
+++ b/services/sync/tps/extensions/tps/resource/tps.jsm
@@ -24,33 +24,32 @@ Cu.import("resource://gre/modules/Timer.
 Cu.import("resource://gre/modules/PromiseUtils.jsm");
 Cu.import("resource://gre/modules/osfile.jsm");
 Cu.import("resource://services-common/async.js");
 Cu.import("resource://services-common/utils.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/main.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-sync/telemetry.js");
-Cu.import("resource://services-sync/bookmark_validator.js");
-Cu.import("resource://services-sync/engines/passwords.js");
-Cu.import("resource://services-sync/engines/forms.js");
-Cu.import("resource://services-sync/engines/addons.js");
 // TPS modules
 Cu.import("resource://tps/logger.jsm");
 
 // Module wrappers for tests
 Cu.import("resource://tps/modules/addons.jsm");
 Cu.import("resource://tps/modules/bookmarks.jsm");
 Cu.import("resource://tps/modules/forms.jsm");
 Cu.import("resource://tps/modules/history.jsm");
 Cu.import("resource://tps/modules/passwords.jsm");
 Cu.import("resource://tps/modules/prefs.jsm");
 Cu.import("resource://tps/modules/tabs.jsm");
 Cu.import("resource://tps/modules/windows.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "getValidator",
+  "resource://sync-repair/collection_repair.jsm");
+
 var hh = Cc["@mozilla.org/network/protocol;1?name=http"]
          .getService(Ci.nsIHttpProtocolHandler);
 var prefs = Cc["@mozilla.org/preferences-service;1"]
             .getService(Ci.nsIPrefBranch);
 
 XPCOMUtils.defineLazyGetter(this, "fileProtocolHandler", () => {
   let fileHandler = Services.io.getProtocolHandler("file");
   return fileHandler.QueryInterface(Ci.nsIFileProtocolHandler);
@@ -624,17 +623,17 @@ var TPS = {
       Logger.logInfo("About to perform bookmark validation");
       let clientTree = await (PlacesUtils.promiseBookmarksTree("", {
         includeItemIds: true
       }));
       let serverRecords = await getServerBookmarkState();
       // We can't wait until catch to stringify this, since at that point it will have cycles.
       serverRecordDumpStr = JSON.stringify(serverRecords);
 
-      let validator = new BookmarkValidator();
+      let validator = getValidator("bookmarks");
       let {problemData} = await validator.compareServerWithClient(serverRecords, clientTree);
 
       for (let {name, count} of problemData.getSummary()) {
         // Exclude mobile showing up on the server hackily so that we don't
         // report it every time, see bug 1273234 and 1274394 for more information.
         if (name === "serverUnexpected" && problemData.serverUnexpected.indexOf("mobile") >= 0) {
           --count;
         }
@@ -652,23 +651,23 @@ var TPS = {
       if (serverRecordDumpStr) {
         Logger.logInfo("Server bookmark records:\n" + serverRecordDumpStr + "\n");
       }
       this.DumpError("Bookmark validation failed", e);
     }
     Logger.logInfo("Bookmark validation finished");
   },
 
-  async ValidateCollection(engineName, ValidatorType) {
+  async ValidateCollection(engineName) {
     let serverRecordDumpStr;
     let clientRecordDumpStr;
     try {
       Logger.logInfo(`About to perform validation for "${engineName}"`);
       let engine = Weave.Service.engineManager.get(engineName);
-      let validator = new ValidatorType(engine);
+      let validator = getValidator(engineName);
       let serverRecords = await validator.getServerItems(engine);
       let clientRecords = await validator.getClientItems();
       try {
         // This substantially improves the logs for addons while not making a
         // substantial difference for the other two
         clientRecordDumpStr = JSON.stringify(clientRecords.map(r => {
           let res = validator.normalizeClientItem(r);
           delete res.original; // Try and prevent cyclic references
@@ -701,25 +700,25 @@ var TPS = {
         Logger.logInfo(`Server state for ${engineName}:\n${serverRecordDumpStr}\n`);
       }
       this.DumpError(`Validation failed for ${engineName}`, e);
     }
     Logger.logInfo(`Validation finished for ${engineName}`);
   },
 
   ValidatePasswords() {
-    return this.ValidateCollection("passwords", PasswordValidator);
+    return this.ValidateCollection("passwords");
   },
 
   ValidateForms() {
-    return this.ValidateCollection("forms", FormValidator);
+    return this.ValidateCollection("forms");
   },
 
   ValidateAddons() {
-    return this.ValidateCollection("addons", AddonValidator);
+    return this.ValidateCollection("addons");
   },
 
   async RunNextTestAction() {
     try {
       if (this._currentAction >= this._phaselist[this._currentPhase].length) {
         // Run necessary validations and then finish up
         if (this.shouldValidateBookmarks) {
           await this.ValidateBookmarks();
--- a/tools/lint/eslint/modules.json
+++ b/tools/lint/eslint/modules.json
@@ -1,10 +1,11 @@
 {
   "AboutHome.jsm": ["AboutHomeUtils", "AboutHome"],
+  "addon_validator.jsm": ["AddonValidator"],
   "AddonManager.jsm": ["AddonManager", "AddonManagerPrivate"],
   "addons.js": ["AddonsEngine", "AddonValidator"],
   "addons.jsm": ["Addon", "STATE_ENABLED", "STATE_DISABLED"],
   "addonsreconciler.js": ["AddonsReconciler", "CHANGE_INSTALLED", "CHANGE_UNINSTALLED", "CHANGE_ENABLED", "CHANGE_DISABLED"],
   "AddonTestUtils.jsm": ["AddonTestUtils", "MockAsyncShutdown"],
   "addonutils.js": ["AddonUtils"],
   "ajv-4.1.1.js": ["Ajv"],
   "AlertsHelper.jsm": [],
@@ -12,27 +13,27 @@
   "AppInfo.jsm": ["newAppInfo", "getAppInfo", "updateAppInfo"],
   "async.js": ["Async"],
   "AsyncSpellCheckTestHelper.jsm": ["onSpellCheck"],
   "AutoMigrate.jsm": ["AutoMigrate"],
   "Battery.jsm": ["GetBattery", "Battery"],
   "blocklist-clients.js": ["AddonBlocklistClient", "GfxBlocklistClient", "OneCRLBlocklistClient", "PluginBlocklistClient"],
   "blocklist-updater.js": ["checkVersions", "addTestBlocklistClient"],
   "bogus_element_type.jsm": [],
-  "bookmark_repair.js": ["BookmarkRepairRequestor", "BookmarkRepairResponder"],
-  "bookmark_validator.js": ["BookmarkValidator", "BookmarkProblemData"],
+  "bookmark_repair.jsm": ["BookmarkRepairRequestor", "BookmarkRepairResponder"],
+  "bookmark_validator.jsm": ["BookmarkValidator", "BookmarkProblemData"],
   "bookmarks.js": ["BookmarksEngine", "PlacesItem", "Bookmark", "BookmarkFolder", "BookmarkQuery", "Livemark", "BookmarkSeparator"],
   "bookmarks.jsm": ["PlacesItem", "Bookmark", "Separator", "Livemark", "BookmarkFolder", "DumpBookmarks"],
   "BootstrapMonitor.jsm": ["monitor"],
   "browser-loader.js": ["BrowserLoader"],
   "browserid_identity.js": ["BrowserIDManager", "AuthenticationError"],
   "CertUtils.jsm": ["BadCertHandler", "checkCert", "readCertPrefs", "validateCert"],
   "clients.js": ["ClientEngine", "ClientsRec"],
-  "collection_repair.js": ["getRepairRequestor", "getAllRepairRequestors", "CollectionRepairRequestor", "getRepairResponder", "CollectionRepairResponder"],
-  "collection_validator.js": ["CollectionValidator", "CollectionProblemData"],
+  "collection_repair.jsm": ["getRepairRequestor", "getAllRepairRequestors", "CollectionRepairRequestor", "getRepairResponder", "CollectionRepairResponder", "getValidator"],
+  "collection_validator.jsm": ["CollectionValidator", "CollectionProblemData"],
   "Console.jsm": ["console", "ConsoleAPI"],
   "constants.js": ["WEAVE_VERSION", "SYNC_API_VERSION", "USER_API_VERSION", "MISC_API_VERSION", "STORAGE_VERSION", "PREFS_BRANCH", "PWDMGR_HOST", "PWDMGR_PASSWORD_REALM", "PWDMGR_PASSPHRASE_REALM", "PWDMGR_KEYBUNDLE_REALM", "DEFAULT_KEYBUNDLE_NAME", "HMAC_INPUT", "SYNC_KEY_ENCODED_LENGTH", "SYNC_KEY_DECODED_LENGTH", "SYNC_KEY_HYPHENATED_LENGTH", "NO_SYNC_NODE_INTERVAL", "MAX_ERROR_COUNT_BEFORE_BACKOFF", "MAX_IGNORE_ERROR_COUNT", "MINIMUM_BACKOFF_INTERVAL", "MAXIMUM_BACKOFF_INTERVAL", "HMAC_EVENT_INTERVAL", "MASTER_PASSWORD_LOCKED_RETRY_INTERVAL", "DEFAULT_BLOCK_PERIOD", "DEFAULT_GUID_FETCH_BATCH_SIZE", "DEFAULT_MOBILE_GUID_FETCH_BATCH_SIZE", "DEFAULT_STORE_BATCH_SIZE", "HISTORY_STORE_BATCH_SIZE", "FORMS_STORE_BATCH_SIZE", "PASSWORDS_STORE_BATCH_SIZE", "ADDONS_STORE_BATCH_SIZE", "APPS_STORE_BATCH_SIZE", "DEFAULT_DOWNLOAD_BATCH_SIZE", "DEFAULT_MAX_RECORD_PAYLOAD_BYTES", "SINGLE_USER_THRESHOLD", "MULTI_DEVICE_THRESHOLD", "SCORE_INCREMENT_SMALL", "SCORE_INCREMENT_MEDIUM", "SCORE_INCREMENT_XLARGE", "SCORE_UPDATE_DELAY", "IDLE_OBSERVER_BACK_DELAY", "URI_LENGTH_MAX", "MAX_HISTORY_UPLOAD", "MAX_HISTORY_DOWNLOAD", "STATUS_OK", "SYNC_FAILED", "LOGIN_FAILED", "SYNC_FAILED_PARTIAL", "CLIENT_NOT_CONFIGURED", "STATUS_DISABLED", "MASTER_PASSWORD_LOCKED", "LOGIN_SUCCEEDED", "SYNC_SUCCEEDED", "ENGINE_SUCCEEDED", "LOGIN_FAILED_NO_USERNAME", "LOGIN_FAILED_NO_PASSWORD", "LOGIN_FAILED_NO_PASSPHRASE", "LOGIN_FAILED_NETWORK_ERROR", "LOGIN_FAILED_SERVER_ERROR", "LOGIN_FAILED_INVALID_PASSPHRASE", "LOGIN_FAILED_LOGIN_REJECTED", "METARECORD_DOWNLOAD_FAIL", "VERSION_OUT_OF_DATE", "DESKTOP_VERSION_OUT_OF_DATE", "SETUP_FAILED_NO_PASSPHRASE", "CREDENTIALS_CHANGED", "ABORT_SYNC_COMMAND", "NO_SYNC_NODE_FOUND", "OVER_QUOTA", "PROLONGED_SYNC_FAILURE", "SERVER_MAINTENANCE", "RESPONSE_OVER_QUOTA", "ENGINE_UPLOAD_FAIL", "ENGINE_DOWNLOAD_FAIL", "ENGINE_UNKNOWN_FAIL", "ENGINE_APPLY_FAIL", "ENGINE_METARECORD_DOWNLOAD_FAIL", "ENGINE_METARECORD_UPLOAD_FAIL", "ENGINE_BATCH_INTERRUPTED", "JPAKE_ERROR_CHANNEL", "JPAKE_ERROR_NETWORK", "JPAKE_ERROR_SERVER", "JPAKE_ERROR_TIMEOUT", "JPAKE_ERROR_INTERNAL", "JPAKE_ERROR_INVALID", "JPAKE_ERROR_NODATA", "JPAKE_ERROR_KEYMISMATCH", "JPAKE_ERROR_WRONGMESSAGE", "JPAKE_ERROR_USERABORT", "JPAKE_ERROR_DELAYUNSUPPORTED", "kSyncNotConfigured", "kSyncMasterPasswordLocked", "kSyncWeaveDisabled", "kSyncNetworkOffline", "kSyncBackoffNotMet", "kFirstSyncChoiceNotMade", "kFirefoxShuttingDown", "FIREFOX_ID", "FENNEC_ID", "SEAMONKEY_ID", "TEST_HARNESS_ID", "MIN_PP_LENGTH", "MIN_PASS_LENGTH", "DEVICE_TYPE_DESKTOP", "DEVICE_TYPE_MOBILE", "SQLITE_MAX_VARIABLE_NUMBER"],
   "Constants.jsm": ["Roles", "Events", "Relations", "Filters", "States", "Prefilters"],
   "ContactDB.jsm": ["ContactDB", "DB_NAME", "STORE_NAME", "SAVED_GETALL_STORE_NAME", "REVISION_STORE", "DB_VERSION"],
   "content-server.jsm": ["init"],
   "content.jsm": ["registerContentFrame"],
   "ContentCrashHandlers.jsm": ["TabCrashHandler", "PluginCrashReporter", "UnsubmittedCrashHandler"],
   "ContentObservers.js": [],
@@ -63,17 +64,18 @@
   "ExtensionXPCShellUtils.jsm": ["ExtensionTestUtils"],
   "NativeManifests.jsm": ["NativeManifests"],
   "fakeservices.js": ["FakeCryptoService", "FakeFilesystemService", "FakeGUIDService", "fakeSHA256HMAC"],
   "file_expandosharing.jsm": ["checkFromJSM"],
   "file_stringencoding.jsm": ["checkFromJSM"],
   "file_url.jsm": ["checkFromJSM"],
   "file_worker_url.jsm": ["checkFromJSM"],
   "Finder.jsm": ["Finder", "GetClipboardSearchString"],
-  "forms.js": ["FormEngine", "FormRec", "FormValidator"],
+  "form_validator.jsm": ["FormValidator", "FormProblemData"],
+  "forms.js": ["FormEngine", "FormRec"],
   "forms.jsm": ["FormData"],
   "FormAutofillHeuristics.jsm": ["FormAutofillHeuristics", "LabelUtils"],
   "FormAutofillSync.jsm": ["AddressesEngine", "CreditCardsEngine"],
   "FrameScriptManager.jsm": ["getNewLoaderID"],
   "fxa_utils.js": ["initializeIdentityWithTokenServerResponse"],
   "fxaccounts.jsm": ["Authentication"],
   "FxAccounts.jsm": ["fxAccounts", "FxAccounts"],
   "FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_FXA_UPDATE_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "PREF_LAST_FXA_USER", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"],
@@ -142,17 +144,18 @@
   "osfile_win_allthreads.jsm": ["declareFFI", "libc", "Error", "AbstractInfo", "AbstractEntry", "Type", "POS_START", "POS_CURRENT", "POS_END"],
   "ospath_unix.jsm": ["basename", "dirname", "join", "normalize", "split", "toFileURI", "fromFileURI"],
   "ospath_win.jsm": ["basename", "dirname", "join", "normalize", "split", "winGetDrive", "winIsAbsolute", "toFileURI", "fromFileURI"],
   "OutputGenerator.jsm": ["UtteranceGenerator", "BrailleGenerator"],
   "PageMenu.jsm": ["PageMenuParent", "PageMenuChild"],
   "PageThumbs.jsm": ["PageThumbs", "PageThumbsStorage"],
   "Parser.jsm": ["Parser", "ParserHelpers", "SyntaxTreeVisitor"],
   "ParseSymbols.jsm": ["ParseSymbols"],
-  "passwords.js": ["PasswordEngine", "LoginRec", "PasswordValidator"],
+  "password_validator.jsm": ["PasswordValidator"],
+  "passwords.js": ["PasswordEngine", "LoginRec"],
   "passwords.jsm": ["Password", "DumpPasswords"],
   "PdfJsNetwork.jsm": ["NetworkManager"],
   "PhoneNumberMetaData.jsm": ["PHONE_NUMBER_META_DATA"],
   "PlacesUtils.jsm": ["PlacesUtils", "PlacesAggregatedTransaction", "PlacesCreateFolderTransaction", "PlacesCreateBookmarkTransaction", "PlacesCreateSeparatorTransaction", "PlacesCreateLivemarkTransaction", "PlacesMoveItemTransaction", "PlacesRemoveItemTransaction", "PlacesEditItemTitleTransaction", "PlacesEditBookmarkURITransaction", "PlacesEditLivemarkFeedURITransaction", "PlacesEditLivemarkSiteURITransaction", "PlacesSetItemAnnotationTransaction", "PlacesSetPageAnnotationTransaction", "PlacesEditBookmarkKeywordTransaction", "PlacesEditBookmarkPostDataTransaction", "PlacesEditItemDateAddedTransaction", "PlacesEditItemLastModifiedTransaction", "PlacesSortFolderByNameTransaction", "PlacesTagURITransaction", "PlacesUntagURITransaction"],
   "PluginProvider.jsm": [],
   "PointerAdapter.jsm": ["PointerRelay", "PointerAdapter"],
   "policies.js": ["ErrorHandler", "SyncScheduler"],
   "prefs.js": ["PrefsEngine", "PrefRec"],
@@ -164,16 +167,17 @@
   "Readability.js": ["Readability"],
   "record.js": ["WBORecord", "RecordManager", "CryptoWrapper", "CollectionKeyManager", "Collection"],
   "recursive_importA.jsm": ["foo", "bar"],
   "recursive_importB.jsm": ["baz", "qux"],
   "reflect.jsm": ["Reflect"],
   "RemoteFinder.jsm": ["RemoteFinder", "RemoteFinderListener"],
   "RemotePageManager.jsm": ["RemotePages", "RemotePageManager", "PageListener"],
   "RemoteWebProgress.jsm": ["RemoteWebProgressManager"],
+  "repair_manager.jsm": ["RepairManager"],
   "resource.js": ["AsyncResource", "Resource"],
   "rest.js": ["RESTRequest", "RESTResponse", "TokenAuthenticatedRESTRequest"],
   "rotaryengine.js": ["RotaryEngine", "RotaryRecord", "RotaryStore", "RotaryTracker"],
   "require.js": ["require"],
   "RTCStatsReport.jsm": ["convertToRTCStatsReport"],
   "scratchpad-manager.jsm": ["ScratchpadManager"],
   "server.js": ["server"],
   "service.js": ["Service"],