APM to Interest Manager Migration Guide

Prev Next

APM to Interest Manager Data Migration Guide

About this guide

This guide walks technical users through moving client preference data from the legacy Ace Preference Manager (APM) into Interest Manager (also known as the Flexible Preference Manager, or FPM). It explains what each migration step does, why it matters, how to run it, and what to check before moving on.

Who it's for Salesforce admins and developers who run the migration as anonymous Apex, preferably in Workbench (see 4). Business owners and QA join at the review and sign-off points.
What you need A login to the target org with the Interest Manager admin permissions, and access to Workbench (workbench.developerforce.com) or Developer Console. You also need Setup access to Apex Jobs and Files. You should be comfortable with SOQL and with reading System.debug output.
Accurate as of 2026-10-20. Reflects the Data Migration Plan, the Runbook and the code (latest code change: commit 2026-10-14).

Related documents:

Document Use it for
Data_Migration_Plan.md The full design, the exact Apex for every step, open items (§6) and sign-off criteria (§7)
Data_Migration_Runbook.html Interactive runbook: fill in parameters, generate snippets, record counters, tick off each checkpoint
Interest_Manager_Client_Center_Coexistence_Blueprint.md How Client Center keeps working alongside Interest Manager, and the switch to RuleBased
Interest Manager Overview What Interest Manager does for business users (Rule Builder, Interests, approvals, search)

The migration code lives in ALM/sfdc/classes/:

  • FPMServiceFacade.cls: the entry point. Every step is an action on the facade.
  • FPMMigrationService.cls: the migration engine.
  • FPMPreferenceRuleBuildBatch.cls: the batch job for the whole-org rule build.
  • FPMRuleExpressionService.cls, FPMRuleGrainService.cls: build readable rule expressions and rule keys.
  • TestFPMMigrationService.cls: tests for all of the above.

1. What the migration does

The core change: from Interest rows to preference rules. APM stored each client preference as a separate Interest__c row pointing at one topic. Interest Manager works differently. Users create preference rules (Preference_Rule__c) in the Rule Builder, for example "Sector IN (Healthcare, Metals)", and Interest Manager generates the Interest rows from each rule. (See the Interest Manager Overview, Part 3.)

The migration therefore:

  1. Copies the legacy taxonomy (topic types, topics and report types) into the Interest Manager taxonomy.
  2. Rebuilds preference rules by grouping each contact's legacy Interests into rules.
  3. Lets Interest Manager regenerate the Interests from those rules.
  4. Checks the result against counters taken before the migration.
  5. Retires the legacy Interests, but only after sign-off and the Client Center switch. Retiring means marking them expired.

Nothing legacy is ever deleted. Both systems share the same Salesforce objects. They are kept apart by which topic tree a record belongs to and by which fields are filled in (see 2).

Key terms

Term Meaning
APM Ace Preference Manager, the legacy app. Its topics sit under the ACE Preference topic type.
Interest Manager (IM / FPM) The new app. Its topics sit under the Flexible Preference topic type (AFR ParentTypeName).
Topic / Topic type Interest_Subject__c / Interest_Subject_Type__c.
Report type Interest_Subject_Type_Attribute__c (ISTA): a report type and format pair attached to a topic type.
Preference rule Preference_Rule__c. The source of truth in Interest Manager. It generates Interest__c rows.
Generic topic types Topic types outside the APM tree, used for general interest tracking. Included by default.
AFR The feature and attribute configuration store, for example ACE.FPM.Migration or ACE.FPM.Settings.
Facade T1C_Base.FPMServiceFacade.execute(req). Every step is run through it (see 4).

2. How the old and new data compare

What Legacy (APM) Interest Manager
Parent topic type ACE Preference Flexible Preference
Where an interest comes from An Interest__c row written directly A Preference_Rule__c that generates Interest__c rows
How an interest points to its topic Interest_Subject__c lookup Topic_Hash__c plus IS_Search01–10__c
Report type and format Interest__c.Report_Type__c, on each interest Preference_Rule__c.Report_Types__c (FPM-RT- tokens), on each rule
Research or general interest Interest__c.Reason__c (optional) Preference_Rule__c.Interest_Reason__c, copied onto the generated Interests
"Not interested" Interest__c.Is_Disinterest__c A rule condition with the NOT IN operator
Research entitlements Preference_Restriction__c with DNC_Type__c = null Research Status Rules (JSON in AFR)
Do Not Contact Not applicable Preference_Restriction__c with DNC_Type__c filled in. Not migrated.

How the two systems stay apart
Interests generated by Interest Manager deliberately leave Interest_Subject__c empty. Because that field is empty, existing Client Center code never sees, de-duplicates, clones or rewrites Interest Manager records. Client Center reads Interest Manager records only through new code, which is switched on by the AFR setting ACE.FPM.Settings / InterestStructureMode (from Legacy to RuleBased). Do not back-fill the legacy fields (see step 2.5).


3. The process at a glance

The migration runs in a set-up stage and three phases. Each phase has checkpoints (gates) that must pass before you continue.

 PRE-FLIGHT (once per org)          configuration, scope decisions, "before" counters
        │
 PHASE 1 — COPY THE TAXONOMY        reversible, no impact on live data
   stageLegacyTopics  ──►  Topic_Mapping__c (Pending)
        ▼   ◄── GATE: the business reviews and corrects the taxonomy
   runTopicImport     ──►  Interest Manager topic types, topics, relationships
   repairRelationships
   stageLegacyReportTypes ──► Interest Manager report types (FPM-RT- tokens)
        │
 PHASE 2 — BUILD RULES AND INTERESTS   writes live data
   buildPreferenceRules       (dry run → pilot → check in the UI)
   buildPreferenceRulesBatch  (whole org)
        ──►  Preference_Rule__c (MIG-)  ──►  Interest__c (generated)
   backfillInterestFields     (report only, do NOT back-fill)
        │
 PHASE 3 — ENTITLEMENTS, VALIDATION AND RETIREMENT
   draftValidationRules  ──►  draft rule JSON + evidence CSV (finished by hand)
   validateMigration     ──►  "after" counters
   ◄── GATE: sign-off (Plan §7)
   ◄── GATE: switch InterestStructureMode to RuleBased (Client Center deliverable)
   retireLegacyInterests ──►  legacy Interest__c marked Expired__c = true (reversible)
Workstream Steps What it produces
W1: Taxonomy stageLegacyTopics, runTopicImport, repairRelationships, stageLegacyReportTypes Interest Manager topic types, topics, relationships and report types
W2: Rules and Interests purgeMigratedRules (only for a rebuild), buildPreferenceRules, buildPreferenceRulesBatch, backfillInterestFields (report only) Migrated MIG- rules and their generated Interests
W3: Entitlements draftValidationRules Draft configuration files only. A person finishes them.
Validation and retirement validateMigration, retireLegacyInterests Counters, and the legacy Interests marked expired

4. How to run a step

Where to run the scripts

Every step is a short piece of anonymous Apex that you paste and run against the target org. Run it as a user with the Interest Manager admin permissions. We recommend Workbench, the same tool the Runbook uses.

Tool How When to use it
Workbench (recommended, tested on API 67.0) Go to workbench.developerforce.com, choose the environment (Sandbox or Production) and API version 67.0, and log in. Then open Utilities → Apex Execute, paste the script and click Execute. Keep the Apex Code log level at Debug. Every step. The results appear as plain text, so you can select and copy the whole run log into the Runbook's Run log for that step. This keeps a record of each run's counters.
Developer Console Setup → Developer Console → Debug → Open Execute Anonymous Window. Paste the script and click Execute, then open the log and tick Debug Only. An alternative if Workbench isn't available. The log is harder to copy out in full.
Salesforce CLI Save the script as step.apex and run sf apex run --file step.apex --target-org <alias>. Repeatable runs where you want the output saved to a file.

If a run stops with Apex CPU time limit exceeded (or any other limit error that isn't caught), the whole run was rolled back and nothing was saved. Make the run smaller (see 9) and try again.

The script pattern

Every step uses the same pattern. Change the action and parameters for each step:

T1C_Base.FPMServiceFacade.ServiceRequest req = new T1C_Base.FPMServiceFacade.ServiceRequest();
req.action = '<action>';
req.params.put('<param>', <value>);
T1C_Base.FPMServiceFacade.ServiceResult result = T1C_Base.FPMServiceFacade.execute(req);
System.debug(result.success + ' : ' + result.message);
System.debug(result.data);

The whole-org rule build (step 2.4) uses the same pattern, but it starts a background job and returns straight away:

T1C_Base.FPMServiceFacade.ServiceRequest req = new T1C_Base.FPMServiceFacade.ServiceRequest();
req.action = 'buildPreferenceRulesBatch';
req.params.put('dryRun', false);      // always pass explicitly; the default is false (writes data)
req.params.put('scopeSize', 20);      // contacts per batch scope
T1C_Base.FPMServiceFacade.ServiceResult result = T1C_Base.FPMServiceFacade.execute(req);
System.debug(result.message);         // contains the jobId; then watch Setup > Apex Jobs

Rules that apply to every step

  • Safe to re-run. You can stop any step, fix the problem and run it again. A second run never creates duplicates.
  • Keep going until it's finished. If the result says hasMore = true (the message ends with MORE RECORDS REMAIN), run the step again until it says false. Two steps hand back a resume cursor that you must pass into the next run:
    • stageLegacyTopics returns nextAfterId. Pass it back as afterLegacyTopicId.
    • buildPreferenceRules returns nextAfterContactId. Pass it back as afterContactId. Without it, each re-run reads the same first contacts again and never moves forward, because legacy rows keep Preference_Rule__c = null until they're retired. Each run stops at the end of a contact, so no contact is split across two runs.
  • Some rows can fail while others succeed. Records are saved in chunks of 200. Errors on individual rows are collected (up to 200) and the step reports success = false. When that happens, stop and review before continuing.
  • If a step times out, halve limitSize and run it again.
  • Always do a dry run first. For every step that writes data and supports dryRun, run it with dryRun = true first. Some steps default to writing (see 5), so always pass dryRun explicitly.
  • Namespace. In a managed-package org, prefix objects and fields with T1C_Base__ in your own SOQL. The Runbook has a namespace switch that does this for you.
  • Use the same scope settings every time. includeGenericTopicTypes, legacyParentTypeName, apmSourceValue and the delivery and language defaults must be the same across stageLegacyTopics, stageLegacyReportTypes and buildPreferenceRules.

5. Step-by-step

5.0 Pre-flight (once per org)

Set these up before the first step. They decide what gets migrated and give you a baseline to compare against at the end.

# Task Why it matters
a Create the AFR setting ACE.FPM.Migration / ReportTypeDeliveryMap. It's JSON that maps each report type (by Value__c, or Name if blank) to delivery methods, separated by semicolons, for example "Weekly Summary": "Email;Portal". Sets the delivery method on migrated research rules. It doesn't change which report types are migrated. Those are copied as data.
b Confirm the Interest Manager parent topic type exists (action = 'getParentTopicTypeId'). Every imported topic type sits under it.
c Set ACE.FPM.Settings / InterestStructureMode = Legacy and ACE.FPM.Migration / BackfillClientCenterFields = false. Client Center keeps reading legacy rows during the migration, and the two systems stay apart.
d Set ACE.FPM.Migration / DefaultDeliveryOptions and DefaultLanguageOptions (for example Email and en_US). The last fallback for research rules. A research rule with no delivery method can never be found by a research search.
e Decide whether to include generic topic types, and record totalTopics (scope query in Plan §3.0e). Step 1.1 is complete when the number of staged rows equals totalTopics.
f Check for topic types nested more than 2 levels deep. The import flattens topic types to two levels. Either accept this in writing or restructure first (Plan §6.2).
g Run validateMigration and save the output. This becomes your "before" baseline. It also shows any inconsistent legacy "not interested" data before you start.

5.1 Phase 1: Copy the taxonomy (W1)

This phase copies APM's topic types, topics and report types into Interest Manager. Nothing here affects live client data, and every step can be reversed.

Step 1.0: Clear old staging rows (one-time, optional)

Only needed on orgs that were staged with an early build. It removes staging rows with the old T1_APM_ / T1_Gen_ prefixes.

Step 1.1: Stage the legacy topics (stageLegacyTopics)

What it does: copies every legacy topic into a staging table (Topic_Mapping__c) with Status = Pending, so the business can review it before anything is imported. Each row gets a stable ID: T1-APM-<id> for APM topics, T1-GEN-<id> for generic ones.

Parameters (defaults): legacyParentTypeName (ACE Preference), limitSize (5000), includeGenericTopicTypes (true), genericTypeNameSuffix ( (General)), afterLegacyTopicId (resume cursor).

Tips:

  • Run an APM-only pass first, then a pass for generic types.
  • On large taxonomies, pass nextAfterId back as afterLegacyTopicId on each run.
  • Name clashes: if a generic topic type has the same name as an APM one (for example Sector), importing would merge them. To prevent that, the generic one is staged as Sector (General), and its original name is kept in Comments__c.

Gate 1.2: Taxonomy review

This is the only cheap place to fix taxonomy mistakes. Before importing, the business and the migration team:

  • Correct topic type and topic names, Tag_Path__c and Parent_Ids__c.
  • Confirm which topic types are canonical and which aren't (NonCanonicalTypeNames).
  • Accept or rename any (General) names now. After import, the name becomes the key.
  • Sign off.

Step 1.3: Import the topics (runTopicImport)

What it does: creates the Interest Manager topic types, topics and parent-child relationships from the reviewed staging rows. It uses the existing, tested FPMMappingService import. Parameter: limitSize (2000).

Step 1.4: Repair relationships (repairRelationships)

Re-links parent and child topics. Run it again after any manual fix to Parent_Ids__c.

Gate 1.5: No errors

mapping_Error = 0 and mapping_Pending = 0.

Step 1.6: Copy the report types (stageLegacyReportTypes)

What it does: copies each legacy report type and format pair onto the matching Interest Manager topic type, with a newly generated FPM-RT-<hash> ID.

Parameters (defaults): dryRun (false, so pass true first), limitSize (5000), includeGenericTopicTypes (true).

Check: it must run after step 1.3 and before Phase 2, and unresolvedReportTypeRows must be 0.

5.2 Phase 2: Build rules and Interests (W2)

This phase turns each contact's legacy Interests into preference rules, and Interest Manager then generates the new Interests from those rules. This is where live data is first written, so it moves in stages: dry run, small pilot, check in the app, then the whole org.

Step 2.0: Remove earlier migrated rules (purgeMigratedRules, one-time, optional)

Only needed if an earlier build ever ran buildPreferenceRules. It deletes MIG- rules and the Interests they generated. It never touches rules created by users or legacy Interests.

Parameters (defaults): dryRun (true), contactIds, limitSize (2000).

Step 2.1: Dry run on a pilot group (buildPreferenceRules)

What it does: works out which rules would be created for a small set of contacts, without saving anything when dryRun = true.

Parameters (defaults): dryRun (false, so pass true), contactIds (5–10 contacts), maxCombos (200), mergeReportTypes (true), includeGenericTopicTypes, apmSourceValue (APM), defaultDeliveryOptions, defaultLanguageOptions, deliveryMapJson (optional override of the delivery map).

Pick pilot contacts that cover the tricky cases:

  • The contact with the most preferences.
  • One with a "not interested" preference.
  • One with a blank Reason__c.
  • One with two report types on the same topics.
  • One with two report types on different topics.
  • One whose preferences span two topic types.

Gate 2.1: Review the dry run

  • topicsUnresolved, reportTypesUnresolved, rowsWithoutReportTypeToken and rulesWithoutDelivery are all 0.
  • No rule covers more than one topic type.
  • Every quarantined contact has a decision (see 9).
  • The research versus general interest split, and the "not interested" counts, add up.
  • reportTypeMergedRules matches what you expect for the two-report-type pilot contacts (see 6.1).

Step 2.2: Run the pilot for real (buildPreferenceRules)

The same call with dryRun = false, the same contactIds, and interestSyncBatchSize (25, no more than 50). This is the first write to live data. Rules and their Interests are saved together, so a failure leaves neither behind.

Gate 2.3: Check the pilot in the app

Open the Rule Builder for each pilot contact and confirm:

  • Each rule reads clearly and covers one topic type only.
  • Research and general interest are set correctly, along with the research status.
  • Delivery methods appear on research rules only.
  • Report types have been carried over.
  • "Not interested" preferences show as NOT IN.
  • Saving a rule without changing it leaves the rule text unchanged.

The business signs off here.

Step 2.4: Build the rest of the org (buildPreferenceRulesBatch, recommended)

What it does: runs the rule build for every remaining contact as a background batch job. Each group of contacts runs separately, with its own processing limits (60 seconds of CPU instead of 10). This avoids the time-outs that a large single run in Workbench or Developer Console would hit.

Parameters (defaults): dryRun (false, so pass it explicitly), scopeSize (contacts per group, default 20), and the same scope settings as step 2.1.

How it works:

  • It processes every contact that still has an active legacy Interest with no rule, narrowed by contactIds if you pass them.
  • afterContactId is ignored, because the batch handles its own progress.
  • limitSize defaults to 10,000 rows per group.
  • It returns a jobId. Track it in Setup → Apex Jobs.
  • When it's done, a summary is saved as a File titled FPM rule build <timestamp>.

Alternative for small orgs or targeted re-runs: run buildPreferenceRules with dryRun = false, limitSize (default 2000) and afterContactId. Re-run it while hasMore is true, passing nextAfterContactId back as afterContactId each time.

Gate 2.4: Clean batch summary

Open the summary File and confirm:

  • Failed scopes = 0 and errors = 0.
  • Truncated scopes = 0. A truncated group had more legacy rows than limitSize, so its last rows weren't migrated. Re-run those contacts (contactIds) with a larger limitSize or a smaller scopeSize.

Step 2.5: Back-fill check (backfillInterestFields, report only)

Do not back-fill. With backfillClientCenterFields = false this step saves nothing. It only reports how many records would be affected (wouldBackfill). Skipping it entirely is also fine. (See 2 for why the legacy fields stay empty.)

5.3 Phase 3: Entitlements, validation and retirement

This phase sets up research entitlements, confirms the migration is complete and correct, and finally retires the legacy data.

Step 3.3: Draft the Research Status Rules (draftValidationRules)

What it does: reads the legacy entitlement records and produces a draft rule JSON and an evidence CSV, both saved as Files. It saves nothing else. Parameter: approvalRoute (DEFAULT).

A person must finish the drafts:

  1. Write each rule's eligibility conditions.
  2. Remove the _migrationNote entries.
  3. Paste the result into the AFR setting ACE.FPM.ValidationRules / ResearchStatusRules.

Important: an empty eligibility block allows everyone. It doesn't block anything, so the rule gives no protection until the conditions are written. (These rules drive the Research Entitlement check described in the Interest Manager Overview, Parts 2 and 10.)

Step 3.5: Validate (validateMigration)

Read-only. Produces the "after" counters. Compare them with the baseline from pre-flight step g, using the targets in section 8.

Gate: Sign-off (Plan §7)

Covers technical checks, report types, rule reconstruction, configuration, business acceptance, and readiness to reverse.

Gate: Switch InterestStructureMode to RuleBased

This is a separate Client Center deliverable (see the Coexistence Blueprint). The RuleBased read and write paths must already be deployed and rehearsed in a full-copy sandbox.

Step 3.6: Retire the legacy Interests (retireLegacyInterests)

What it does: marks legacy Interests as expired (Expired__c = true). It never deletes anything. Only contacts that now have a MIG- rule are included.

  1. Dry run first: dryRun (default true).
  2. Then run it for real: dryRun = false, limitSize (5000).

Run it only after the RuleBased switch, never before.


6. How legacy data becomes preference rules

6.1 How legacy Interests are grouped into rules

Each migrated rule covers one contact × topic type × reason × operator × set of delivery methods × report type. In practice:

  • Topics of the same type, reason and direction are combined into one rule, for example Sector IN (Healthcare, Metals).
  • A rule always covers exactly one topic type. If one rule held two topic types, Interest Manager would generate combined Interests (for example Materials; Gold) that the client never asked for.
  • Interested and not interested in the same topic type become two rules (IN and NOT IN).
  • Different report types become separate rules, so a rule never claims report types the contact didn't choose for those topics.
  • Recommended but optional: rules with different report types but has the same topics are merged (mergeReportTypes, default true). Two rules are merged when everything matches (contact, topic type, reason, operator, delivery methods, languages and exactly the same topics) and only the report type differs. The merged rule lists every report type in Report_Types__c, for example FPM-RT-a;FPM-RT-b. Search matches any report type in the list, so the merged rule finds exactly what the separate rules would have found, with half as many generated Interests.
    • Rules are never merged when the report type is blank (blank means "all report types"), when the topics only partly overlap, when the delivery methods or languages differ, or when the combined list would go over 255 characters.
    • The number of merged rules is reported as reportTypeMergedRules. Pass mergeReportTypes = false to keep one rule per report type.

6.2 Research or general interest

Every migrated rule is prefixed with MIG-. It is either a research rule or a general interest rule (see the Interest Manager Overview, Part 3). It's decided from the legacy row like this:

Legacy row Becomes
Reason__c = Research Research
Reason__c = Interest General interest
Blank reason and Source__c = 'APM' (apmSourceValue) Research
Blank reason, any other source General interest
Any other value (for example Holding or Disinterest) General interest (counted as nonStandardReasonRows)
  • "Not interested" doesn't change the reason. A row with Is_Disinterest__c = true becomes a NOT IN condition, but the rule stays research or general interest as before, because "don't send me research on X" is still a research preference. Only an explicit Reason__c = 'Disinterest' becomes general interest.
  • Research rules carry delivery methods and languages, and take part in research distribution.
  • General interest rules are saved as research-inactive (Research_Status__c = Inactive, no delivery methods). They're never locked or sent for approval. Their Interests are still generated, and they're still found by general interest searches.

6.3 Delivery methods and languages (research rules only)

Each research rule needs a delivery method, or research searches can never find it. The migration looks for one in this order, and stops at the first match:

  1. The ReportTypeDeliveryMap (pre-flight step a).
  2. The contact's own Research_Delivery_Preferences__c / Research_Language_Preferences__c.
  3. This run's defaultDeliveryOptions / defaultLanguageOptions.
  4. The AFR defaults (pre-flight step d).
  5. Blank.

Blank results are counted and must be 0. Languages have no report type mapping, so they start at step 2. All lists use semicolons.

6.4 "Not interested" and expired rows

  • "Not interested" preferences are migrated as NOT IN rules. The generated Interests have Is_Disinterest__c = true and Reason__c = 'Disinterest' (AFR DisinterestReason).
  • Only the Is_Disinterest__c flag decides direction. Legacy rows where the reason and the flag disagree are counted (disinterestReasonWithoutFlag / disinterestFlagWithoutReason in the build, and the same names with a legacy prefix in validateMigration). Review them before the run.
  • Expired legacy Interests are not migrated. Re-creating them would bring back preferences the client dropped. They're counted as legacyExpiredExcluded. To migrate one, un-expire it in the legacy data first.

6.5 Report types

A report type is a qualifier on the rule, not a topic, so topic searches work exactly as before. A blank Report_Types__c means all report types. The Preference Search API accepts an optional reportTypes filter (POST only).

6.6 Readable rule text

Rule_Expression__c is built on the server by FPMRuleExpressionService and matches what the Rule Builder shows, for example Sector (APM) IN (Healthcare, Metals). It's rebuilt every time the rule is saved.


7. Safety, re-runs and rollback

7.1 Stable keys (why re-runs never duplicate)

Every record the migration creates has a key that comes out the same on every run. A re-run finds and updates the existing record instead of adding a new one.

Data Key Notes
Staged topics Topic_Mapping__c.External_Id__c = T1-APM-<legacyId> / T1-GEN-<legacyId> The prefixes avoid _ and %, which are SOQL wildcards.
Imported topics Interest_Subject__c.Topic_Unique_Key__c (TypeName::TopicName) Uses the existing runTopicImport logic.
Report types Interest_Subject_Type_Attribute__c.External_Id__c = FPM-RT-<hash10> Generated fresh, never copied, and unique across the org. A re-run overwrites manual renames, so rename only after the final run.
Migrated rules Preference_Rule__c.Grain_Key__c = MIG-<ContactId>-<hash8> The hash covers the whole grouping (see 6.1). Name is just a display label, for example MIG: Sector Interest. Rules captured in Client Center use CC-. Merged report types are sorted first, so a merged rule gets the same key every time.
Generated Interests Topic_Hash__c + Is_Disinterest__c Matched by FPMInterestService.

7.2 Triggers are switched off during the migration

stageLegacyReportTypes, buildPreferenceRules, backfillInterestFields and retireLegacyInterests switch off triggers (ACETriggerCheck.disableAll = true) for their own transaction only. This stops migrated rules from being locked for approval, avoids "too many queueable jobs" errors and doubled audit records, and avoids unnecessary list subscription updates.

What this means: migrated records have no Preference_Audit__c history, so they don't appear in the Audit Trail. The migration's own record is in FPM_Log__c, marked with [FPMMigration].

7.3 How to roll back

Reverse the steps in the opposite order to how you ran them.

Step How to reverse it
retireLegacyInterests Set Expired__c = false. Nothing was deleted.
draftValidationRules Delete the generated files.
backfillInterestFields Clear Interest_Subject__c / Interest_Subject_Type__c where Preference_Rule__c != null.
buildPreferenceRules / buildPreferenceRulesBatch Run purgeMigratedRules with dryRun = false. Deleting rules by hand only expires their Interests. Stop any running batch in Setup → Apex Jobs first.
stageLegacyReportTypes Delete report types where External_Id__c LIKE 'FPM-RT-%'. Reverse the rules first. This also removes any report types added by hand.
runTopicImport Delete topics matching the staged Topic_Unique_Key__c. Only do this after the rules are reversed and no Interests use the topics.
stageLegacyTopics Delete Topic_Mapping__c rows by prefix (T1-APM-%, T1-GEN-%, or T1-%).

Check after every run: this query must return 0. If it doesn't, legacy APM entitlement data was changed:

System.debug([SELECT COUNT() FROM Preference_Restriction__c
              WHERE DNC_Type__c = null AND LastModifiedDate = TODAY]);

8. What "done" looks like (validateMigration targets)

Counter Target
mapping_Error, mapping_Pending 0
legacyReportTypesUnmapped 0
istaRowsStaged Matches legacyReportTypesTotal, minus skipped duplicates
researchRulesWithoutDelivery 0
interestRulesResearchActive 0
migratedRulesWithoutExpression 0
dncWithOneDateOnly 0
migratedRules Expected number of contacts × several rules per contact
migratedRulesResearchReason / migratedRulesInterestReason Match the build's researchReasonRules / interestReasonRules
migratedRulesWithNoReportType Matches the legacy Interests with no report type. These rules cover all report types, which isn't an error.
imDisinterests Matches disinterestRules × topics
legacyInterestsExpiredExcluded Matches legacyExpiredExcluded from the build runs
interestsAtSearchFieldCap 0, or accepted in writing
backfillEnabled false
imInterestsMissingCCFields Expected to be above 0. It shows the two systems are correctly kept apart (see 2). It isn't a defect.

9. Run sizes and limits

Step Recommended size Notes
stageLegacyTopics limitSize 500–5000 Up to 20 windows per run. Carry nextAfterId forward.
runTopicImport limitSize 2000
stageLegacyReportTypes limitSize 5000 Low volume.
buildPreferenceRules limitSize up to 2000 (default 2000) interestSyncBatchSize 25 (no more than 50). Large runs can exceed the 50,000 query-row limit and the 10-second CPU limit, so use this only for the pilot and targeted re-runs.
buildPreferenceRulesBatch scopeSize 20 (default); limitSize 10,000 rows per group 60 seconds of CPU per group. Lower scopeSize if groups fail on CPU or memory. Raise limitSize if groups are reported as truncated.
backfillInterestFields limitSize 5000 Saves nothing while back-fill is off.
retireLegacyInterests limitSize 5000 Updates a single field.
draftValidationRules limitSize 5000 Read-only.

Oversized rules are set aside, not forced through. If a draft rule would generate more than maxCombos (200) Interests, it's quarantined: written to a CSV (quarantineFileId) instead of being saved. Decide what to do for each quarantined contact individually. Don't raise maxCombos for everyone.


10. Who decides what

Decision Owner
Taxonomy review at Gate 1.2 (names, parents, (General) names, canonical types) Business owner and migration team
Flattening topic types to two levels (Plan §6.2) Accept in writing, or restructure first
Topic_Hash__c 255-character limit (Plan §6.3): a risk at about 13 or more topics per combination Decide and apply before go-live
Writing the eligibility conditions for Research Status Rules, and proving that a restricted topic is blocked (Plan §6.5) A named owner is required
No Audit Trail history on migrated records (see 7.2) Audit compliance owner
The pilot rule sample, and the research versus general interest split Business owner
Confirming apmSourceValue matches what APM actually wrote Migration team
The RuleBased switch and Client Center conversion (M1, M4, M5, M6) Client Center development team (see the Coexistence Blueprint)

11. Change history: documents vs. code

The code is the authority. On 2026-10-02 the Plan and Runbook were checked against FPMMigrationService.cls and corrected where they differed:

Topic The documents said The code actually does
Rule key Preference_Rule__c.Name = MIG-<ContactId>-<hash8> The key is Grain_Key__c. Name is a display label like MIG: <TopicType> <Interest/Disinterest label>. Queries using Name LIKE 'MIG-%' (for example Plan §6.3 and the §2.5 rollback text) should use Grain_Key__c LIKE 'MIG-%'.
Back-fill default "The default is true and must be changed to false" Already false (DEFAULT_BACKFILL_CC = false).
Retirement safety check "Must be changed to use InterestStructureMode = RuleBased" Already done. Retirement goes ahead when the org is RuleBased, or back-fill is on, or acknowledgeClientCenterGap = true, or no Interest Manager records are missing Interest_Subject__c.
dryRun defaults Snippets always pass it buildPreferenceRules and stageLegacyReportTypes default to false (they write). purgeMigratedRules and retireLegacyInterests default to true. Always pass dryRun.
stageLegacyTopics limitSize Snippets use 500 The code default is 5000. Snippets stay at 500 for the APM-first pass.