Skip to content

QM v2 Migration Strategy — Zero-Disruption Rollout

Concrete migration steps from the current embedded question model to the QM v2 versioning architecture (DraftQuestion, AnnotationQuestion, PQS, SQS). Designed for zero downtime and per-project rollback. Covers data migration, system questions, annotation versioning integration, feature flag sequencing, and the annotation form transition.


Table of Contents

  1. Principles
  2. Migration Overview
  3. Pre-Migration: Additive Schema Changes (Zero Risk)
  4. Step 1: Create pmAnnotationQuestion Collection (Backend Only)
  5. Step 2: Per-Project Data Migration
  6. Step 3: Backfill Annotation Version References
  7. Step 4: Frontend Feature Flag Rollout
  8. Step 5: Annotation Form Transition
  9. Step 6: Legacy Decommission
  10. System Question Migration Detail
  11. Rollback Procedures
  12. Risk Matrix
  13. Environment Sequencing

1. Principles

Principle Why
Additive, never destructive New fields/collections alongside old ones. Old data untouched until proven safe.
Per-project granularity Each project migrates independently. One project's failure doesn't affect others.
Feature-flag gated newQuestionManagement (admin UI) and annotationFormV2 (annotator UI) are independent flags. Each can be toggled without the other.
Dual-model period Both old and new models coexist. Old code reads old data; new code reads new data. No shared writes.
Rollback at every step Each step has a documented reversal that restores the previous state.
System questions are first-class They get their own migration path because they have a fundamentally different lifecycle.

2. Migration Overview

                    CURRENT STATE                          TARGET STATE
              ┌──────────────────────┐             ┌──────────────────────┐
              │ pmProject             │             │ pmProject             │
              │  ├ AnnotationQs[]     │   ──────►   │  ├ AnnotationQs[]     │ (preserved, read-only)
              │  │ (embedded, mutable)│             │  ├ DraftQuestions[]    │ (NEW)
              │  ├ Stages[]           │             │  ├ ProjectQuestionSet  │ (NEW)
              │  │  └ AQs: Set<Guid>  │             │  ├ DraftSnapshots[]    │ (NEW)
              │  └ SysQVersion: int   │             │  ├ Stages[]            │
              └──────────────────────┘             │  │  └ StageQuestionSet │ (NEW)
                                                    │  └ SysQVersion: int    │
              ┌──────────────────────┐             └──────────────────────┘
              │ pmStudy               │
              │  └ Annotations[]      │             ┌──────────────────────┐
              │     └ QuestionId      │   ──────►   │ pmAnnotationQuestion  │ (NEW collection)
              └──────────────────────┘             │  ├ _id (= old AQ GUID)│
                                                    │  ├ publishedVersions[] │
                                                    │  └ draft: {...}        │
                                                    └──────────────────────┘

                                                    ┌──────────────────────┐
                                                    │ pmStudy               │
                                                    │  └ Annotations[]      │
                                                    │     ├ QuestionId      │ (unchanged)
                                                    │     └ QuestionVersionId│ (NEW, nullable)
                                                    └──────────────────────┘

Key insight: The old AnnotationQuestions[] array on pmProject is never modified or deleted during migration. It becomes a read-only archive that unmigrated code paths continue to use. This is the cornerstone of zero-disruption rollback.


3. Pre-Migration: Additive Schema Changes (Zero Risk)

When: Deploy with normal CI/CD. No migration needed. No feature flag changes.

What: Ship the new domain entities and empty collection. The application code is deployed but the new code paths are dormant (behind feature flag = false).

Change Location Impact on Running System
Create pmAnnotationQuestion collection with projectId index MongoDB None — empty collection, nothing reads it
Add DraftQuestions: [] field default to Project entity C# model None — deserialization ignores missing fields; new writes include empty array
Add ProjectQuestionSet: null field default to Project entity C# model None — null default
Add StageQuestionSet: null field default to Stage entity C# model None — null default
Add DraftSnapshots: [] field default to Project entity C# model None — empty array
Add nullable QuestionVersionId to Annotation entity C# model None — existing annotations deserialize with null
Add /api/v2/ endpoints (behind feature flag check) PM service None — endpoints return 403 when flag is off

Backwards Compatibility: The MongoDB C# driver ignores unknown fields by default. Existing code reading pmProject or pmStudy documents will not see the new fields and won't break. New code writes default values alongside existing fields.

Rollback: Remove the code deployment. Empty collection auto-cleaned. No data was touched.

Deliverables: - New domain entity classes with unit tests - Repository implementations for pmAnnotationQuestion - v2 API controllers (gated by feature flag) - Collection creation + indexing in startup or migration script


4. Step 1: Create pmAnnotationQuestion Documents (Backend Only)

When: After Step 0 is deployed and stable. Triggered by a migration command, not by deployment.

What: For each project, create the new AnnotationQuestion documents in pmAnnotationQuestion and the initial PQS/SQS versions on the Project/Stage documents. This is a read-from-old, write-to-new operation.

4.1 Per-Project Migration Sequence

For each project P:
  1. LOCK: Acquire advisory lock on P (prevent concurrent migration)

  2. READ old state:
     - P.AnnotationQuestions[] (embedded custom questions)
     - P.SystemQuestionVersion (determines system question content)
     - P.Stages[].AnnotationQuestions (HashSet<Guid> per stage)

  3. CREATE system question AQ documents (see Section 10):
     For each of the 17+1 system question GUIDs:
       - Create AnnotationQuestion in pmAnnotationQuestion
         _id = hardcoded GUID
         projectId = P._id
         system = true
         publishedVersions = [AQVersion v1 with content from SystemQuestionCreators]
         draft = null (system questions never have drafts)

  4. CREATE custom question AQ documents:
     For each custom question in P.AnnotationQuestions[]:
       - Create AnnotationQuestion in pmAnnotationQuestion
         _id = question.Id (GUID preserved)
         projectId = P._id
         system = false
         publishedVersions = [AQVersion v1 with current question content]
         draft = null (clean state — no pending changes)
         category, parentQuestionId, dataType, groupAsSingle = frozen from current
         scope = "project", ownerProjectId = P._id

  5. CREATE initial PQS on Project:
     - DraftPQS.orderedQuestions = all question IDs, type = "published"
       (system questions first within each category, then custom, preserving
        the order from the existing embedded array)
     - PQSVersion v1:
       orderedQuestionRefs = [{questionId, versionId: AQVersion._id}]
       for ALL questions (system + custom)

  6. CREATE initial SQS on each Stage:
     For each stage S in P.Stages[]:
       - DraftSQS.questionIds = S.AnnotationQuestions (the existing HashSet)
         PLUS all system question GUIDs (if data extraction stage)
       - SQSVersion v1:
         pqsVersionId = PQSVersion v1._id
         questionIds = same set

  7. SET migration flag:
     P.MigrationStatus = "migrated"
     P.MigratedAt = DateTime.UtcNow

  8. VALIDATE:
     - Count of AQ docs == count of custom questions + 17/18 system questions
     - Every stage's SQS questionIds is a subset of PQS questionIds
     - Every annotation's QuestionId maps to an existing AQ doc
     - PQS ordering is consistent (no duplicates, no orphans)

  9. RELEASE lock

4.2 Fixing the 32 Known Production Violations

The characterization tests from the formal specification (Appendix A, 2025-12-27) found 32 custom questions in categories that require a parent (DMI, Treatment, Outcome, Cohort, Experiment) but have Target = null. These are in 24 projects, mostly from 2017–2019.

A production query (2026-03-25) checked which of these 32 questions have annotations against them. The result splits them into two groups:

23 Questions with Zero Annotations — Fix Before Migration

These questions have never been answered. Fixing their parent relationship is safe and has no downstream impact. During migration (or as a pre-migration cleanup step), set Target and parentQuestionId to the correct system question for their category:

  • DMI questions → parent to DMI Control (b18aa936-...)
  • Treatment questions → parent to Treatment Control (d04ec2d7-...)
  • Outcome questions → parent to OA Label (dbe2720c-...)
  • Experiment questions → parent to Experiment Label (7c555b6e-...)

This brings them into alignment with the placement rules before they are frozen as AnnotationQuestion documents in the new model.

9 Questions with Annotations — Migrate via Legacy Experiment Instance Plan

These 9 questions are all in the Experiment category and therefore must be re-parented to Experiment Label in the new model. The migration cannot simply attach historical answers to arbitrary experiment instances, because their existing annotations were captured at study scope. To preserve data integrity without keeping an invalid hierarchy, the migration uses a deterministic per-study mapping plan:

Project Question Category Annotations
PC12 OGD Data Extraction Statistical Test Used Experiment 22
SR Tool Feature Analysis Is HPLC used? Experiment 12
Cannabinoids What was the time (post model induction;hours) of outcome assessment measurement where the difference between control and treatment is greatest? Experiment 4
Cannabinoids What was the time (post model induction;hours) of the last outcome assessment measurement? Experiment 1
ChABC SCI Number of Controls Experiment 1
ChABC SCI Number in Treatment Group Experiment 1
ChABC SCI Total Number of Animals? Experiment 1
ChABC SCI Number of Sham Animals Experiment 1
exposed to A have effects on B is this a trial or observation study Experiment 1

Migration algorithm:

  1. Dry-run inventory every affected project and study:
  2. impacted question IDs, annotation counts, and answer payload counts
  3. existing Experiment units per study
  4. whether each study has zero, one, or multiple candidate Experiment instances
  5. Re-parent the question definition to Experiment Label before the AQ document is frozen, so the resulting AnnotationQuestion complies with the system-question hierarchy.
  6. Map existing answers per study using a no-guess rule:
  7. If the study has exactly one existing Experiment instance, attach the migrated answers to that instance.
  8. If the study has zero or multiple Experiment instances, create a synthetic legacy Experiment instance for migration and attach all impacted answers to that instance.
  9. Create audit metadata for every migrated answer and synthetic instance:
  10. source question ID
  11. source study ID
  12. migration run ID / timestamp
  13. mapping mode (existing-instance or synthetic-legacy-instance)
  14. Validate before commit:
  15. answer count preserved exactly
  16. no answer duplicated or dropped
  17. every migrated answer references a valid Experiment instance and a valid AQVersion
  18. no affected question remains parentless after migration
  19. Validate after commit:
  20. project-level reconciliation report matches pre-migration counts
  21. affected stages render the question under Experiment Label
  22. quantitative export / annotation reconstruction smoke tests pass for the 5 affected projects

This preserves every historical answer exactly once while bringing the question definitions into compliance with the system-question model. The synthetic legacy instance is intentionally explicit: it preserves historical scope without pretending the system can infer which experiment instance an old root-level answer belonged to.

Strategy Summary

Group Count Action Rationale
Zero annotations 23 Fix parent before migration No data impact; brings questions into compliance before freezing
Has annotations 9 Re-parent to Experiment Label and migrate answers into existing or synthetic Experiment instances Preserves answers while enforcing the canonical system-question hierarchy
All future questions Prevent new violations New validation blocks creating parentless questions in categories that require one

4.3 Dry-Run Mode

The migration service supports dryRun: true which: - Reads all data and performs validation - Outputs what WOULD be created (AQ count, PQS structure, SQS assignments) - Writes nothing - Reports any anomalies (missing questions, orphan references, placement violations)

Rollback: Delete all pmAnnotationQuestion documents with projectId = P._id. Remove ProjectQuestionSet, DraftQuestions, and StageQuestionSet fields from the Project document. The existing AnnotationQuestions[] array is untouched and continues to work.


5. Step 2: Backfill Annotation Version References

When: After Step 1 for a project. Can run immediately after or as a separate pass.

What: Add QuestionVersionId to every existing annotation on pmStudy, pointing to the corresponding AQVersion v1.

// Pseudocode — actual implementation in C# via MongoDb driver
for each study S where S.ProjectId == P._id:
  for each annotation A in S.Annotations[]:
    AQ = pmAnnotationQuestion.findOne({_id: A.QuestionId, projectId: P._id})
    if AQ exists:
      A.QuestionVersionId = AQ.publishedVersions[0]._id  // v1
    else:
      log.warn("Annotation references unknown question", A.QuestionId)
      A.QuestionVersionId = null  // tolerate orphan

Why separate from Step 1: Study documents can be large and numerous. Batching this separately avoids long-running transactions that block the migration lock.

Performance: Process in batches of 100 studies. Use bulkWrite with updateOne operations. Expected throughput: ~1000 studies/minute on Atlas M20.

Rollback: $unset the QuestionVersionId field from all annotations:

db.pmStudy.updateMany(
  { ProjectId: CSUUID("project-id") },
  { $unset: { "Annotations.$[].QuestionVersionId": "" } }
)

6. Step 3: Frontend Feature Flag Rollout

When: After Steps 1–2 for target projects. The backend is ready; this step enables the new admin UI.

6.1 Staged Rollout

Stage Scope Duration Gate
Internal SyRF team test projects only 1 week Team validates all views work, publish flow succeeds
Small projects Projects with < 20 questions, < 5 stages 1 week No user-reported issues
Medium projects Projects with < 100 questions 1 week Data export still produces identical output
All projects Enable for all migrated projects Ongoing Monitor for 2 weeks

6.2 Feature Flag Architecture

newQuestionManagement:
  scope: per-project (not global)
  default: false
  controls:
    - Sidebar nav shows Questions > Design/Assign/Preview
    - Admin routes to v2 components
    - API calls go to /api/v2/ endpoints
    - Question loading via SQS → PQS → pmAnnotationQuestion chain

annotationFormV2:
  scope: global (affects all users, not just admins)
  default: false
  controls:
    - Unit mini cards with select-to-show workspace
    - Signal forms replacing reactive forms
    - Per-unit form state
  NOTE: Independent of newQuestionManagement.
        Can be enabled for unmigrated projects too
        (renders existing questions with new UI).

6.3 What Users See

Admins (when newQuestionManagement = true for their project): - Sidebar nav gains "Questions" section with Design, Assign, Preview - Old QM interface no longer accessible for this project - All question editing, assignment, publishing happens through v2 UI - Autosave replaces manual save

Annotators (when annotationFormV2 = false, which is the default): - No visible change. The annotation form loads questions through the new backend chain (SQS → PQS → AQ) but renders them identically to before. - Annotations now record QuestionVersionId alongside QuestionId — transparent to the annotator.

Annotators (when annotationFormV2 = true, later rollout): - Unit tiles, mini cards, multi-expand workspace - Same questions, same conditional logic, new presentation

Rollback: Set newQuestionManagement = false for the project. Admin UI reverts to legacy QM. Backend continues serving old data from embedded AnnotationQuestions[]. The new data in pmAnnotationQuestion sits dormant.


7. Step 4: Annotation Form Transition

When: After Step 3 is stable for all projects. This is the second, independent feature flag rollout.

7.1 Two Layers of Change

The annotation form has two independent concerns:

Concern Flag Who It Affects Risk Level
Question resolution backend newQuestionManagement Annotators (indirectly — same questions, new loading path) Low — output is identical
UI: unit tiles, signal forms annotationFormV2 Annotators (directly — new interaction patterns) Medium — UX change

These are deployed independently:

  1. First: Enable newQuestionManagement → annotators unknowingly use the new backend. Monitor that question rendering is identical.
  2. Later: Enable annotationFormV2 → annotators see the new UI. This can be rolled back independently.

7.2 Annotation Form Dual-Model Support

During the transition, the annotation form component must handle both:

// Simplified resolution logic
if (project.migrationStatus === 'migrated') {
  // New path: SQS → PQS → pmAnnotationQuestion
  const sqsVersion = stage.stageQuestionSet.latestVersion;
  const pqsVersion = project.projectQuestionSet.getVersion(sqsVersion.pqsVersionId);
  const questions = resolveQuestions(pqsVersion, sqsVersion);
  // Each annotation records QuestionVersionId on save
} else {
  // Legacy path: embedded questions from Project
  const questions = project.annotationQuestions.filter(q => stage.annotationQuestions.has(q.id));
  // QuestionVersionId is null on save (unmigrated)
}

7.3 Data Export Compatibility

Critical invariant: Data export must produce byte-identical output regardless of which model a project uses.

Export Concern Why It's Safe
OutcomeData population Uses QuestionId (hardcoded system GUID), not QuestionVersionId. Unchanged.
OutcomeDataFormatRowWriter Queries annotations by QuestionId. The GUID is preserved through migration.
Custom question export Queries annotations by QuestionId. The GUID is preserved. Version info is additional metadata, not a breaking change.
Annotation grouping Uses QuestionId + AnnotatorId + StudyId as composite key. Unchanged.

Validation: Run data export for migrated projects and diff against pre-migration export. Must be identical.


8. Step 5: Legacy Decommission

When: After all projects migrated, all feature flags enabled, stable period of 4+ weeks.

What: Remove the dual-model support code and the old embedded data.

Action When Reversible?
Remove feature flag checks from frontend After 100% rollout + 4 weeks stable Yes (re-add checks)
Remove legacy QM v1 components After feature flag removal Yes (git revert)
Remove old API endpoints After frontend no longer calls them Yes (git revert)
Archive AnnotationQuestions[] from pmProject After 8+ weeks stable Yes (restore from archive)
Drop dual-model support from annotation form After annotationFormV2 is 100% Yes (git revert)

The embedded AnnotationQuestions[] array is the LAST thing to go. It serves as the rollback source for the entire migration. Only remove it after extended production stability.


9. System Question Migration Detail

System questions require special handling because they are dynamically generated in the current system (not persisted) but must become persisted versioned entities in the new system.

9.1 Migration Creates, Not Updates

For system questions, Step 1 is a CREATE operation, not an update:

Current: System questions exist only as C# constants + factory methods
         No document in any MongoDB collection

After:   AnnotationQuestion document in pmAnnotationQuestion
         _id = hardcoded GUID (e.g., bdb6e257-5a08-42ef-aad0-829668679b0e)
         system = true
         publishedVersions = [AQVersion v1 with content from SystemQuestionCreators]

9.2 SystemQuestionVersion-Dependent Content

The CreateOutcomeErrorTypeQuestion factory method generates different content based on Project.SystemQuestionVersion. The migration must:

  1. Read Project.SystemQuestionVersion
  2. Call SystemQuestionCreators[errorTypeGuid]​(project) to get the version-correct content
  3. Persist that content as AQVersion v1

This means two projects with different SystemQuestionVersion values will have different AQVersion v1 content for the Error Type question. That's correct — each project's system questions reflect its configured version.

9.3 Post-Migration: Explicit System-Question Upgrade Handling

After migration, if a platform update bumps the system question definitions:

  1. A system admin runs an explicit upgrade command or admin workflow after the new SystemQuestionVersion ships
  2. The upgrade service performs a dry-run diff showing:
  3. affected system questions
  4. projects currently on the older version
  5. whether each project can be upgraded immediately or deferred
  6. The admin chooses all projects or a subset of projects
  7. For the selected projects, the admin chooses an execution mode:
  8. Immediate upgrade: create new AQVersions for affected system questions and append a new PQS version now
  9. Mark pending for lazy upgrade: record the project as pending and create the new AQVersions/PQS version the next time QM loads that project
  10. Stages show "system questions updated — publish to apply" until the stage is republished onto the newer PQS version
  11. The upgrade run writes execution metadata for audit, retry, and rollback analysis

This makes the version bump an intentional operational event, while still allowing lazy per-project completion when the system admin decides that is the safer rollout mode.

9.4 System Questions in the Assign View

System questions are auto-included in data extraction stages and cannot be unassigned:

  • Checkbox: checked + disabled
  • Badge: "System" with lock icon
  • Not counted in "pending changes" totals
  • Not part of admin publish actions (they version separately)

9.5 System Questions and OutcomeData

No change to data export. The OutcomeDataFormatRowWriter identifies system question annotations by QuestionId (the hardcoded GUID). The GUID is the _id of the new AnnotationQuestion document — same value, different storage location. Export code never needs to know about QuestionVersionId.


10. Rollback Procedures

Every step has independent rollback. Rollback order is reverse of migration order.

Per-Project Rollback (Reverses Steps 1–3)

1. Set feature flag: newQuestionManagement = false for project P
   → Admin UI immediately reverts to legacy QM
   → Annotators unaffected (annotation form falls back to legacy path)

2. Remove QuestionVersionId from annotations:
   db.pmStudy.updateMany(
     { ProjectId: CSUUID("P._id") },
     [{ $set: { "Annotations": {
       $map: { input: "$Annotations", as: "a", in: {
         $mergeObjects: ["$$a", { QuestionVersionId: null }]
       }}
     }}}]
   )

3. Clear new fields from Project document:
   db.pmProject.updateOne(
     { _id: CSUUID("P._id") },
     { $unset: {
       "ProjectQuestionSet": "",
       "DraftQuestions": "",
       "DraftSnapshots": "",
       "MigrationStatus": "",
       "MigratedAt": ""
     }}
   )

4. Clear StageQuestionSet from each Stage:
   db.pmProject.updateOne(
     { _id: CSUUID("P._id") },
     { $unset: { "Stages.$[].StageQuestionSet": "" } }
   )

5. Delete AQ documents for this project:
   db.pmAnnotationQuestion.deleteMany({ projectId: CSUUID("P._id") })

6. Verify: Existing AnnotationQuestions[] on Project is intact
   → This was NEVER modified during migration

Global Rollback (Reverses Step 0)

Only needed if the schema changes themselves cause problems (extremely unlikely):

  1. Set all feature flags to false
  2. Deploy previous application version (git revert)
  3. Drop pmAnnotationQuestion collection
  4. The application reads existing embedded data as before

11. Risk Matrix

Risk L I Mitigation Detection
Migration creates incorrect AQVersion content Low High Dry-run mode validates all content before writing. Diff old vs new. Post-migration validation queries
System question GUID collision with custom question Impossible Critical System GUIDs are hardcoded constants that never overlap with random GUIDs Migration validates uniqueness
Concurrent annotation during migration Medium Medium Migration runs per-project with advisory lock. Annotations save to Study (different document). Lock duration: < 30 seconds per project. Lock timeout alerts
pmProject document exceeds 16MB after adding PQS/SQS Low High PQS and SQS are compact (ordered GUIDs). Even 2000 questions = ~80KB of refs. DraftSnapshots capped at 50 with deduplication. Document size monitoring post-migration
Data export produces different output Low Critical Export uses QuestionId (preserved GUID), not QuestionVersionId. Integration test: export before and after migration, diff output. CI test with production data snapshot
Feature flag rollback causes state inconsistency Low Medium Old code reads old data (AnnotationQuestions[]); new data in pmAnnotationQuestion is dormant. No shared writes during dual-model period. Smoke tests after flag toggle
SystemQuestionVersion bump during migration window Low Medium Freeze the target SystemQuestionVersion per migration run. If the platform ships a newer version mid-run, complete the current run and schedule a separate explicit upgrade run for affected projects. Version check in migration validation + upgrade run audit trail
Annotated Experiment migration maps answers to the wrong unit Low High No-guess rule: reuse an existing Experiment instance only when exactly one exists; otherwise create a synthetic legacy instance. Validate counts before/after and retain mapping audit metadata. Dry-run classification report + per-project reconciliation checks

12. Environment Sequencing

Timeline

Week 1: Step 0 — Deploy schema changes to staging
        Verify: no regressions, empty collection exists, new fields default correctly
        Run: full test suite, data export comparison

Week 2: Pre-migration cleanup — Repair all 32 placement violations
        Run: dry-run to classify 23 zero-annotation fixes and the 9 annotated Experiment migrations
        Fix: set Target + parentQuestionId on 23 questions; stage legacy Experiment instance mappings for the 9 annotated questions
        Verify: per-project reconciliation reports, affected-stage rendering, and export smoke tests for the 5 impacted projects
        Step 1 — Migrate staging projects
        Run: dry-run first, then actual migration
        Verify: AQ documents created, PQS/SQS populated, validation passes
        Step 2 — Backfill annotation version refs on staging
        Verify: all annotations have QuestionVersionId

Week 3: Step 3 — Enable newQuestionManagement flag on staging
        Internal testing: Design view, Assign view, Preview, publish flow
        Verify: publish creates new AQVersions, PQS/SQS versions
        Run: data export comparison (must be byte-identical)

Week 4: Step 0 — Deploy schema changes to production
        Step 1 — Migrate production projects (batch: 10 at a time, smallest first)
        Step 2 — Backfill production annotations
        Verify per-batch: validation passes, data export unchanged

Week 5: Step 3 — Enable newQuestionManagement on production
        Staged rollout: internal → small → medium → all (see §6.1)
        Monitor: error rates, export correctness, admin feedback

Week 6+: Step 4 — Enable annotationFormV2 (separate rollout)
         Staged: internal → small sample → all
         Monitor: annotator feedback, session completion rates

Week 10+: Step 5 — Legacy decommission (after 4+ weeks stable)

Monitoring Checklist (Per Environment)

Metric Threshold Action
pmAnnotationQuestion document count = expected per project Alert if mismatch
Data export diff (migrated vs unmigrated) 0 differences Block further rollout
API error rate on /api/v2/ endpoints < 0.1% Investigate, consider rollback
Annotation save latency (p95) < 500ms (same as baseline) Investigate if degraded
pmProject document size (max) < 4MB Alert if approaching
Migration duration per project < 60 seconds Investigate outliers
Feature flag toggle latency < 1 second N/A (config change)

Appendix A: Relationship to PR2398 Annotation Versioning

The QM v2 migration (this document) establishes the question versioning foundation. The full annotation versioning model from PR2398 is a subsequent workstream that builds on top:

QM v2 Migration (This Document) PR2398 Annotation Versioning (Future)
AnnotationQuestion with publishedVersions[] Annotation with annotationVersions[] (AV)
PQSVersion / SQSVersion SessionVersion (ASV) pinning AV refs
QuestionVersionId on existing Annotation Full AnnotationVersion entity with pendingAnswer, committedBy, createdByAction
Annotations remain embedded on pmStudy Annotations extracted to pmAnnotation collection
Sessions remain embedded on pmStudy Sessions extracted to pmAnnotationSession collection
Admin publishes stages (new AQVersions) Admin Decision Framework evaluates session impact
No session pinning (latest SQS always used) ASV reconstruction algorithm, PAC evaluation

Sequencing: QM v2 migration MUST complete before PR2398 annotation versioning begins. The QuestionVersionId backfill (Step 2) creates the foreign key that PR2398's AnnotationVersion model will reference.

Publish Decision Initialization During Migration

When the migration creates AQVersion v1 for each question (Step 1), the publish decision fields are initialized as follows:

Field Initial Value Rationale
breakingChange false Migration preserves existing semantics — no answer validity change
publishDecisions [] (empty array) No admin decisions needed — v1 is the baseline, not a change
audit.action "migration" Distinguishes migration-created versions from admin-created ones
audit.triggeredBy migration run ID Links to migration execution metadata for traceability

No DraftPublishDecision is created during migration because there are no pending changes — the draft field on each AQ is null (clean state). The publish decision workflow only activates post-migration when an admin edits a published question that has annotations, at which point the properties panel's Impact & Mapping section auto-expands (see mental-model-review-decisions.md Section 9).

Annotation and Session Extraction — Explicit Deferral

QM v2 migration keeps annotations and sessions embedded on pmStudy. Extraction to dedicated collections (pmAnnotation, pmAnnotationSession) is explicitly deferred to a future phase because:

  1. Not required for question versioning — the QuestionVersionId backfill (Step 2) is sufficient to link annotations to AQ versions while they remain embedded
  2. Avoids compounding migration risk — extracting annotations is a high-volume data transformation that should not be bundled with the question model migration
  3. Independent rollback — keeping extraction separate means the QM v2 migration can be rolled back without also unwinding annotation extraction

What this means for Phases 1–7: - Annotations remain on pmStudy.Annotations[] with the new QuestionVersionId field - Concurrent annotators still contend on the Study document (existing behaviour, unchanged) - Per-annotation pendingAnswer auto-save (from PR2398) is NOT available until extraction - Full annotation version history (AnnotationVersion entity) is NOT available until extraction

Planned future phase (after QM v2 Phases 1–9 are stable): - Extract annotations from pmStudy to pmAnnotation collection - Extract sessions from pmStudy to pmAnnotationSession collection - Enable per-annotation pendingAnswer, full AV history, and ASV-based session pinning - See annotation-versioning-integration.md Section 5.5 for the extraction scope

Appendix B: Decision Log

# Decision Rationale
D1 Per-project migration, not global batch Limits blast radius. Allows staged rollout. Individual project rollback.
D2 Preserve embedded AnnotationQuestions[] throughout migration Serves as rollback source. Old code continues to read it. Only removed in final decommission.
D3 System questions jump straight to AnnotationQuestion (skip DraftQuestion) System questions are never in draft state. They are system-defined and published from birth.
D4 SystemQuestionVersion bumps are handled by an explicit system-admin upgrade workflow, with per-project immediate or lazy execution modes Makes system-question changes an intentional operational event, supports dry-run review, and still allows lazy per-project completion when desired.
D5 Two independent feature flags (newQuestionManagement, annotationFormV2) Admin UI changes and annotator UI changes have different risk profiles and different user populations. Independent rollback.
D6 Backfill QuestionVersionId as a separate step from AQ document creation Study documents can be large and numerous. Separate batching avoids long locks.
D7 Repair all 32 placement violations during migration Production query (2026-03-25) confirmed 23 of 32 violated questions have zero annotations and can be fixed directly. The remaining 9 annotated Experiment questions are re-parented to Experiment Label and their answers are migrated into deterministic existing or synthetic legacy Experiment instances so the final model is consistent and no data is lost.
D8 Data export uses QuestionId (not QuestionVersionId) Hardcoded GUIDs are the stable identity. Version refs are additional metadata. Export code unchanged.