Skip to content

Question Management v2: Implementation Plan

Phased implementation plan covering the gap analysis between PR2398 plans and the new UI specification, migration strategy, backwards compatibility, and build order.


Table of Contents

  1. Gap Analysis: PR2398 Plans vs New Spec
  2. Current State Summary
  3. Implementation Phases
  4. Migration Strategy
  5. Backwards Compatibility
  6. Risk Register
  7. Angular Conventions
  8. Component Architecture
  9. Undo/Redo Architecture
  10. Unmerged Branch Triage
  11. Acceptance Criteria

Work Tracking

Implementation is tracked via GitHub Issues and ZenHub:

Phase Issue Estimate Status
1. Data Model Foundation #2489 5 pts Sprint Backlog
2. Migration Infrastructure #2490 5 pts Sprint Backlog
3. Backend API #2491 8 pts Sprint Backlog
4. Design View (Frontend) #2492 8 pts Sprint Backlog
5. Assign View (Frontend) #2493 5 pts Sprint Backlog
6. Preview View (Frontend) #2494 3 pts Sprint Backlog
7. Annotation Form v2 #2495 8 pts Sprint Backlog
8. Admin Decision Framework #2496 5 pts Sprint Backlog
9. Production Migration & Rollout #2497 3 pts Sprint Backlog

Total: 50 story points. Each phase issue contains checkbox sub-tasks in the description. All assigned to @chrissena.

Sub-issues (broken out from phases for independent tracking):

Sub-issue Parent Phase Issue Estimate
Focus mode for deep nesting Phase 4 #2498 2 pts

Related PRs: #2461 (current WIP branch)


1. Gap Analysis: PR2398 Plans vs New Spec

What the New Spec Adds (Not in PR2398)

Addition Impact Notes
Project Question Set (PQS) Major -- new entity, consistency boundary PR2398 has QSV (per-stage) but no project-level set. PQS is the validated whole from which SQS is a subset. New MongoDB collection needed.
Autosave model Medium -- replaces pendingChanges buffer PR2398 specifies server-side pendingChanges on AQ entities. New spec uses client-side autosave with no manual Save action. The pendingChanges concept may still be useful server-side but the UX is different.
WYSIWYG tree + properties panel layout Medium -- new UI architecture PR2398 describes tabs (Design/Assign/Preview) within QM. New spec moves these to sidebar nav and adds the split-panel workspace layout.
Focus mode for deep nesting Low -- pure UI feature PR2398 doesn't address deep nesting UX. New spec adds breadcrumb-based focus mode at depth 6+.
Mini card unit tiles Medium -- new annotation form pattern PR2398 doesn't detail annotation form unit UX. New spec adds collapsible mini cards with select-to-show workspace.
Sidebar nav integration Low -- navigation restructure PR2398 assumes QM tabs. New spec uses sidebar sub-items.
No version numbers in UI Low -- visual design choice PR2398 shows "v3" badges prominently. New spec removes them in favour of "Draft"/"Published with changes" indicators.

What PR2398 Specifies That the New Spec Defers or Omits

PR2398 Feature Status in New Spec Action Required
Admin Decision Framework Referenced but not detailed Must be implemented per PR2398 spec. When SQS changes affect active sessions, the admin chooses: pin to old version, carry forward, require re-answer. The ASV reconstruction algorithm (PR2398 README lines 242-262) is the authoritative spec.
pendingChanges server-side buffer Replaced by autosave Need to decide: keep pendingChanges as the server-side mechanism for autosave, or use a different approach. The buffer concept is sound -- autosave writes to it, publish reads from it.
Breaking change flag on AQVersions Not detailed in new spec Must implement per PR2398. Each AQVersion carries BreakingChange: boolean set by admin. Transitivity: v2->v5 is breaking if any intermediate version was breaking.
SessionVersion pinning Not in new spec Must implement per PR2398. Each annotation session pins specific AQVersions via SessionVersion. No "latest" resolution at annotation time.
Side-by-side version diff Simplified in new spec PR2398 describes a two-version comparison tool. New spec has an expandable diff in the version history dialog. The new approach is simpler but covers the same need.
16 validation rules (AQ001-AQ016) Not referenced in new spec Must implement. These are the formal placement rules from the annotation-questions formal specification. Already partially implemented in the backend (AnnotationQuestionPlacementValidator).
32 production violations Not referenced Characterization tests found 32 violations in production. Migration must repair all 32, including the 9 annotated Experiment questions, via a deterministic migration plan with validation gates.
pmAnnotationQuestion collection Not in new spec PR2398 specifies a new MongoDB collection for AQ entities separate from Project. This aligns with the PQS model -- AQs need independent identity for versioning.
pmQuestionSet collection Not in new spec PR2398 specifies this for QSV storage. In the new model, this becomes the PQS/SQS collection(s).
Optimistic concurrency on Study Not in new spec Required for concurrent annotation sessions. Must implement per PR2398.
scope and ownerProjectId Not in new spec Future cross-project sharing support. Should be included in the data model now to avoid breaking changes later.

Terminology: Quantitative Data Extraction Rename (PDD-18)

All references to "data extraction" in the question management UI must be renamed to "quantitative data extraction" and "data export" to "quantitative data export". This differentiates the specific workflow requiring system questions for entity relationships from general annotation data extraction.

Affected UI elements: - Sidebar navigation: "Quantitative Data Extraction" (stage name), "Quantitative Data Export" (section) - Assign view info banner: "Quantitative data extraction is enabled for this stage -- system questions are automatically assigned and locked" - System question properties panel: bar_chart icon with "Quantitative data extraction" label - All tooltips and descriptions referencing data extraction in the QM context

Icon: System questions related to quantitative data extraction use the Material Symbol bar_chart to visually associate them with the quantitative data extraction workflow.

This is a frontend-only change (Phases 4-6) with no backend impact. Existing backend naming (e.g., Stage.DataExtractionEnabled) is unchanged.

Reconciliation: How to Merge Both Specs

The new UI spec and PR2398 are complementary, not contradictory. The new spec focuses on UX and information architecture. PR2398 focuses on data model and backend behaviour. The key reconciliation points:

  1. PQS is an addition to PR2398, not a replacement: PR2398's QSV becomes the SQS in the new model. The PQS is a new layer above it. The QSV's structure (immutable ordered list of AQVersionIds with parent integrity) is preserved -- it just becomes the SQS.

  2. pendingChanges becomes the autosave mechanism: PR2398's pendingChanges buffer on AQ entities IS the server-side implementation of the new spec's autosave. The UX removes the manual "Save" button, but the server stores drafts in pendingChanges. The publish step reads from pendingChanges and creates AQVersions.

  3. Admin Decision Framework is unchanged: The new spec doesn't modify this -- it defers to PR2398. The framework activates when an SQS update affects sessions with active annotations.

  4. Validation rules are unchanged: The 16 formal validation rules and the AnnotationQuestionPlacementValidator apply to both draft and published states.


2. Current State Summary

Backend

Component State Notes
AnnotationQuestion entity Exists (780 lines) Embedded in Project aggregate. Has v0/v1 schema versioning. 17 system questions with hardcoded GUIDs.
Stage.AnnotationQuestions Exists HashSet -- stores question IDs only, not full objects.
Annotation entity Exists Stores QuestionId (Guid) + denormalized Question (text). No version reference.
AnnotationQuestionPlacementValidator Exists (219 lines) Validates placement rules. Partially covers the 16 formal rules.
pmAnnotationQuestion collection Does NOT exist Questions are embedded in pmProject. Needs to be extracted for independent versioning.
pmQuestionSet collection Does NOT exist Needs to be created for PQS/SQS storage.
Versioning infrastructure Does NOT exist No AQVersion, no PQS, no SQS entities. All new.
Optimistic concurrency Does NOT exist on Study Needs implementation for concurrent annotation safety.

Frontend

Component State Notes
Legacy QM (v1) Live in production Question designer + stage question selection. Uses ngrx Store.
QM v2 Design tab ~95% complete (PR2461) Uses SignalStore, template-driven forms with Vest validation, Material tree. Inline editing (not split panel). Will be rebuilt with signal forms, schema validation, and zoneless components.
QM v2 Assign tab ~95% complete (PR2461) Dual-tree approach (will be replaced with single-tree + checkboxes).
QM v2 Preview tab ~30% complete (PR2461) Basic rendering, not interactive.
Annotation Form Live, complex (943 lines) RxState-based. Handles units, conditional logic, sessions. NO unit collapsing currently.
Feature flag Exists (newQuestionManagement) false in staging/production, true in PR previews.
Sidebar nav Standard project admin nav Does NOT have "Questions" sub-items. Needs restructuring.

Key Migration Constraints

  1. Questions are embedded in pmProject -- extracting them to a separate collection requires careful data migration
  2. Annotations reference QuestionId only -- no version reference. Adding questionVersionRef requires migrating all existing annotations
  3. System questions are generated dynamically -- they don't persist in the database. The versioning model needs to handle this.
  4. 32 production violations exist -- migration must tolerate these while preventing new ones
  5. 17 system question GUIDs are hardcoded -- any versioning model must preserve these identities

3. Implementation Phases

Phase Order Rationale

The phases are ordered to: 1. Never break existing functionality (feature flag protects users) 2. Build data model foundations before UI 3. Allow incremental testing and rollback at each phase 4. Ship value to users as early as possible (Design view first) 5. Block publishing new AQ versions to stages with annotation sessions until Phase 8 can handle session impact safely

Phase 1: Data Model Foundation

Goal: Create the new entity model for PQS/SQS/AQ versioning without affecting the existing system.

Domain Model Structure

Entity / Value Object Classification
Type Classification Mutability Location
DraftQuestion Entity (within Project aggregate) Fully mutable. Can be deleted entirely. Embedded in pmProject.DraftQuestions[]
AnnotationQuestion Entity (own aggregate) Structural props immutable after publish. Content via draft field. Cannot be deleted (only removed from draft PQS). Own document in pmAnnotationQuestion
AQVersion Value Object (with reference ID) Immutable. Embedded in AnnotationQuestion.publishedVersions[]
DraftContent Value Object Mutable (autosaved). Embedded in AnnotationQuestion.draft
ProjectQuestionSet Entity (singleton within Project) Draft is mutable. Versions are append-only. Embedded in pmProject.ProjectQuestionSet
DraftPQS Value Object Mutable (autosaved). Embedded in ProjectQuestionSet.draft
PQSVersion Value Object (with reference ID) Immutable. Embedded in ProjectQuestionSet.versions[]
QuestionReference Value Object Immutable. Within DraftPQS.orderedQuestions[]
QuestionRef Value Object Immutable. Within PQSVersion.orderedQuestionRefs[]
StageQuestionSet Entity (singleton within Stage) Draft is mutable. Versions are append-only. Embedded in Stage.StageQuestionSet
DraftSQS Value Object Mutable (autosaved). Null before first configuration. Embedded in StageQuestionSet.draft
DraftSnapshot Value Object Immutable once created. Embedded in Project.DraftSnapshots[] (capped, GFS retention)
DraftQuestionSnapshot Value Object Immutable. Embedded within DraftSnapshot.questionContents[]
SQSVersion Value Object (with reference ID) Immutable. Embedded in StageQuestionSet.versions[]
Data Model
pmProject (existing, extended)
  ├── Name, Settings, Memberships (existing)
  ├── DraftQuestions: [                         ← ENTITIES within Project aggregate
  │     DraftQuestion {                            fully mutable, deletable
  │       _id: Guid                              ← identity (carried forward on publish)
  │       category: string                       ← mutable (not yet frozen)
  │       parentQuestionId: Guid?                ← mutable (can ref DraftQuestion or AQ)
  │       dataType: string                       ← mutable
  │       groupAsSingle: bool                    ← mutable
  │       text: string                           ← mutable
  │       options: QuestionOption[]              ← mutable
  │       helpText: string                       ← mutable
  │       answerOptionFilters: object            ← mutable
  │       controlType: string                    ← mutable
  │       optional: bool                         ← mutable
  │       multiple: bool                         ← mutable
  │       replacesAnnotationQuestionId: Guid?    ← links to original AQ (for "Replace with new version")
  │       draftPublishDecision: DraftPublishDecision? ← preliminary impact decision (when original has annotations)
  │     }
  │   ]
  ├── ProjectQuestionSet: {                     ← ENTITY (singleton per project)
  │     draft: {                                ← VALUE OBJECT (DraftPQS), mutable
  │       orderedQuestions: [                   ← ordered array
  │         QuestionReference {                 ← VALUE OBJECT
  │           questionId: Guid
  │           type: "draft" | "published"       ← discriminator
  │         }
  │       ]
  │     }
  │     versions: [                             ← append-only
  │       PQSVersion {                          ← VALUE OBJECT, immutable
  │         _id: Guid                           ← reference ID
  │         versionNumber: int                  ← sequential
  │         orderedQuestionRefs: [              ← ordered array (published only)
  │           QuestionRef {                     ← VALUE OBJECT
  │             questionId: Guid
  │             versionId: Guid                 ← points to AQVersion._id
  │           }
  │         ]
  │         createdBy: Guid
  │         createdAt: DateTime
  │         audit: VersionAudit                 ← provenance (who, when, why, triggered by)
  │       }
  │     ]
  │   }
  ├── Stages[]
  │     ├── ... (existing stage fields)
  │     └── StageQuestionSet: {                 ← ENTITY (singleton per stage)
  │           draft: {                          ← VALUE OBJECT (DraftSQS), mutable
  │             questionIds: Set<Guid>          ← unordered (order from PQS)
  │           }                                    null before first configuration
  │           versions: [                       ← append-only
  │             SQSVersion {                    ← VALUE OBJECT, immutable
  │               _id: Guid                     ← reference ID
  │               versionNumber: int
  │               pqsVersionId: Guid            ← which PQS version this subsets
  │               questionIds: Set<Guid>        ← unordered
  │               publishDecisionSummary: object ← summary for session transition engine
  │               createdBy: Guid
  │               createdAt: DateTime
  │               audit: VersionAudit           ← provenance (who, when, why, triggered by)
  │             }
  │           ]
  │         }
  ├── DraftSnapshots: [                          ← capped array (e.g., max 50), GFS retention
  │     DraftSnapshot {                          ← VALUE OBJECT, immutable once created
  │       _id: Guid
  │       savedAt: DateTime
  │       tier: "son" | "father" | "grandfather" ← GFS retention tier
  │       audit: VersionAudit                    ← provenance (who, when, why, triggered by)
  │       orderedQuestions: [QuestionReference]   ← draft PQS ordering at snapshot time
  │                                                 (or ref to previous snapshot if unchanged)
  │       questionContents: [                    ← only questions with changes since last snapshot
  │         DraftQuestionSnapshot {              ← VALUE OBJECT
  │           questionId: Guid
  │           content: { text, options, ... }    ← full content if changed
  │           referencesSnapshotId: Guid?        ← pointer to previous snapshot if unchanged
  │         }
  │       ]
  │     }
  │   ]
  └── SystemQuestionVersion: int (existing)


pmAnnotationQuestion                            ← ENTITY (own aggregate, only after publish)
  ├── _id: Guid                                 ← immutable (same GUID as DraftQuestion)
  ├── projectId: Guid                           ← immutable
  ├── category: string                          ← immutable (frozen at publish)
  ├── system: bool                              ← immutable
  ├── parentQuestionId: Guid?                   ← immutable (frozen at publish)
  ├── dataType: string                          ← immutable (frozen at publish)
  ├── groupAsSingle: bool                       ← immutable (frozen at publish)
  ├── draft: {                                  ← VALUE OBJECT (DraftContent), mutable
  │     text, options, helpText,                   null when no pending changes
  │     answerOptionFilters, controlType,
  │     optional, multiple,
  │     lastModified: DateTime
  │     draftPublishDecision: DraftPublishDecision? ← preliminary impact decision (when AQ has annotations)
  │   }
  ├── publishedVersions: [                      ← append-only (always >= 1 entry)
  │     AQVersion {                             ← VALUE OBJECT, immutable
  │       _id: Guid                             ← reference ID (annotations point here)
  │       versionNumber: int                    ← sequential within this AQ
  │       text, options, helpText,
  │       answerOptionFilters
  │       createdBy: Guid
  │       createdAt: DateTime
  │       changeReason: string?
  │       breakingChange: bool
  │       publishDecisions: PublishDecision[]    ← immutable record of admin decisions at publish time
  │       audit: VersionAudit                   ← provenance (who, when, why, triggered by)
  │     }
  │   ]
  ├── scope: string                             ← future (cross-project sharing)
  └── ownerProjectId: Guid                      ← future (cross-project sharing)


pmStudy (existing, extended)
  └── Annotations[]
        ├── QuestionId: Guid                    ← existing, preserved
        ├── QuestionVersionId: Guid?            ← NEW, nullable, points to AQVersion._id
        └── ... (existing fields unchanged)
Design Rationale

Two question types with different lifecycles:

  • DraftQuestion (embedded in Project): Fully mutable, can be deleted entirely. Represents in-progress design work. No downstream references. Lives within the Project aggregate because it is lightweight and the Project enforces its validation rules.
  • AnnotationQuestion (own collection/aggregate): Created when a DraftQuestion is first published. Structural properties frozen. Cannot be deleted (only removed from draft PQS). Has its own document because it is edited frequently via autosave, grows with version history, and is referenced by annotations and PQS versions.

Publish ceremony promotes DraftQuestion to AnnotationQuestion: The DraftQuestion is removed from Project.DraftQuestions and an AnnotationQuestion document is created in pmAnnotationQuestion with the same GUID and its first AQVersion. The draft PQS reference changes from {type: "draft"} to {type: "published"} in the resulting PQS version.

Typed references in draft PQS: The QuestionReference value object carries a type discriminator ("draft" or "published") so consumers know where to resolve each ID — Project.DraftQuestions or pmAnnotationQuestion. Published PQS versions use QuestionRef (questionId + versionId) — no discriminator needed because published versions only contain published questions.

Ordering: The draft PQS and published PQS versions contain a single ordered array of all root questions. Child ordering is derived by filtering this array to children of a given parent (using parentQuestionId on each question). Stage ordering is derived from the PQS ordering filtered by the SQS question set.

SQS contains no version references: The SQS references a PQS version, which already contains the {questionId, versionId} pairs. No need to duplicate version references on the SQS.

No archived flag on AQ: Whether a question is "active" is determined by its presence in the draft PQS — not by a flag on the AQ document. The draft PQS is the single source of truth for active questions.

Draft snapshots (GFS retention, deduplication): Periodic snapshots of the draft PQS and all draft question contents, stored on the Project. Provides self-service recovery ("go back to how things looked yesterday") without relying on database-level backups. Key design choices:

  • Only draft questions are snapshotted — published AQ versions are already immutable and permanent. The snapshot captures DraftQuestion content and published AQ draft fields.
  • Deduplication via reference — if a question hasn't changed since the last snapshot, the entry references the previous snapshot rather than storing a copy. This keeps snapshot size minimal.
  • GFS retention — Son (every 15 min of active editing, keep 24h), Father (daily, keep 7 days), Grandfather (weekly, keep 30 days). Pruning promotes and discards at each tier boundary.
  • Capped — hard limit on total snapshots per project (e.g., 50) prevents unbounded growth.
  • Restore granularity — admin can restore the entire draft PQS state from a snapshot, OR restore a single question's draft content from a snapshot, leaving everything else as-is.
  • Embedded on Project — snapshots are small enough (deduplication, draft-only) to embed on the Project document. Can be extracted to pmDraftSnapshot collection if size becomes a concern.
  • Snapshot triggers — every N minutes of active editing, on navigation away from Design view, on session end, before publish (captures pre-publish draft state).

Scope:

  • Create pmAnnotationQuestion collection with projectId index
  • Define C# domain entities: DraftQuestion, AnnotationQuestion, AQVersion, DraftContent, ProjectQuestionSet, DraftPQS, PQSVersion, QuestionReference, QuestionRef, StageQuestionSet, DraftSQS, SQSVersion, DraftSnapshot, DraftQuestionSnapshot
  • Define DraftPublishDecision, PublishDecision, and VersionAudit value objects
  • Extend Project entity with DraftQuestions, ProjectQuestionSet
  • Extend Stage entity with StageQuestionSet
  • Include scope and ownerProjectId fields for future cross-project sharing
  • Implement autosave (server-side draft field on AQ entities, written via debounced API calls)
  • Add nullable QuestionVersionId to Annotation entity model — do NOT migrate existing annotations yet
  • Implement optimistic concurrency on Study document writes

Backwards Compatibility:

  • New pmAnnotationQuestion collection exists alongside existing embedded questions
  • Existing code continues to use Project.AnnotationQuestions (embedded) — untouched
  • New fields on Project/Stage are empty arrays (no impact on existing reads)
  • No migration of existing data in this phase
  • Feature flag remains false

Dependencies: None (foundational)

Deliverables:

  • New domain entities with unit tests
  • MongoDB collection creation and indexing (projectId index on pmAnnotationQuestion)
  • Repository implementations for new collection
  • No frontend changes

Phase 2: Migration Infrastructure

Goal: Build the tooling to migrate existing question data to the new model, without running it yet.

Scope:

  • Build migration service: QuestionMigrationService
  • Reads embedded questions from pmProject.AnnotationQuestions
  • Creates corresponding AnnotationQuestionV2 entities in pmAnnotationQuestion
  • Creates initial PQS v1 containing all project questions (each at AQ v1)
  • For each stage with questions: creates SQS v1 referencing the PQS v1
  • Repairs the 23 zero-annotation violations directly
  • Repairs the 9 annotated Experiment violations via the explicit legacy-root migration plan (synthetic or existing Experiment unit mapping with pre-flight validation)
  • Preserves system question GUIDs (identity continuity)
  • Backfills questionVersionRef on existing annotations (pointing to AQ v1)
  • Build system question upgrade service: SystemQuestionUpgradeService
  • Produces a dry-run diff when SystemQuestionVersion changes
  • Lets the SyRF system admin select all projects or a subset of projects to upgrade
  • Supports two execution modes: immediate upgrade now, or mark selected projects as pending for lazy upgrade on next QM load
  • Records upgrade execution metadata for audit and rollback planning
  • Build rollback service: ability to reverse the migration per-project
  • Build validation service: compares old and new data to confirm migration correctness
  • Dry-run mode: can simulate migration without writing

Backwards Compatibility:

  • Migration is per-project, not global
  • Projects can be migrated independently
  • Unmigrated projects continue using the old model
  • Feature flag controls which model the frontend uses

Dependencies: Phase 1

Deliverables:

  • QuestionMigrationService with dry-run and rollback
  • SystemQuestionUpgradeService with dry-run, subset selection, and execution records
  • Migration validation tests against production data snapshot (local MCP)
  • Documentation: migration runbook

Phase 3: Backend API for Question Versioning

Goal: Implement the backend API endpoints for the new versioning model.

Scope:

  • Draft question CRUD (autosave via pendingChanges)
  • Publish stage endpoint (cascading PQS/SQS/AQ version creation)
  • Cross-question validation service (conditional reference integrity)
  • Version history retrieval
  • "Apply as draft" from previous version
  • AQ version diff comparison
  • Admin Decision Framework skeleton (per PR2398 spec -- can be stubbed for now)
  • Breaking change flag on AQVersions

API Endpoints:

# Draft operations (autosave)
PUT /api/v2/projects/{projectId}/questions/{questionId}/draft

# Publishing
POST /api/v2/projects/{projectId}/stages/{stageId}/publish
POST /api/v2/projects/{projectId}/stages/publish-all

# Version history
GET /api/v2/projects/{projectId}/questions/{questionId}/versions
POST /api/v2/projects/{projectId}/questions/{questionId}/apply-version

# Validation
GET /api/v2/projects/{projectId}/stages/{stageId}/validate

# Question set queries
GET /api/v2/projects/{projectId}/question-set/current
GET /api/v2/projects/{projectId}/stages/{stageId}/question-set/current

Backwards Compatibility:

  • New endpoints under /api/v2/ prefix -- old endpoints unchanged
  • Old ProjectController question endpoints continue to work for unmigrated projects
  • Feature flag determines which API version the frontend calls

Dependencies: Phase 1, Phase 2 (migration must be possible before API is useful)

Deliverables:

  • New API controller(s) with integration tests
  • Domain services for publishing, validation, version management
  • MassTransit events for version creation (for downstream consumers)

Phase 4: Design View (Frontend)

Goal: Build the new Design view with WYSIWYG tree + properties panel.

Scope:

  • Project sidebar nav restructure (Questions > Design, Assign, Preview)
  • Category tabs (horizontal, above workspace)
  • WYSIWYG question tree (left panel):
  • Control type icons (from existing SyRF icon mappings)
  • L-shaped tree connectors
  • Chevrons on left for expand/collapse
  • Full question text (no truncation)
  • Draft/Published-with-changes/Warning indicators
  • Drag-and-drop reordering
  • Focus mode for depth 6+: breadcrumb bar, subtree re-rooting, "Show full tree" exit, keyboard (Escape) support
  • Properties panel (right panel):
  • Resizable via drag handle (min 280px, max 60vw)
  • Conditionality section at top (parent relationship + Show When condition)
  • All question fields (signal forms with schema validation, zoneless)
  • Behaviour toggles in compact 2-column grid (Required, Multiple, Answer array)
  • Contextual information about how properties relate to each other
  • Validation error highlighting: selecting an invalid question highlights the specific invalid field(s) with inline error descriptions
  • Cross-question impact preview
  • Version history dialog with "Apply as draft"
  • Annotation count display
  • Impact & Mapping section (auto-expands when editing published AQ with annotations; configures DraftPublishDecision)
  • "Replace with new version" action for published questions (creates new DraftQuestions with replacesAnnotationQuestionId lineage link, replaces entire published subtree)
  • Discard draft changes action
  • Inline expand mode (toggle between split panel and inline, full property parity including conditionality-first layout)
  • Autosave integration (debounced, writes to pendingChanges via API)
  • Undo/redo stack (session-level)

Backwards Compatibility:

  • Behind feature flag -- legacy QM still works when flag is off
  • Uses new v2 API endpoints (Phase 3)
  • Migrated projects only (Phase 2 must have run for this project)

Dependencies: Phase 3 (API), Phase 2 (project must be migrated)

Deliverables:

  • Angular components: workspace, tree, properties panel, version history dialog
  • SignalStore for question management state
  • Signal forms with schema validation for properties editing (zoneless components)
  • Unit and integration tests

Phase 5: Assign View (Frontend)

Goal: Build the new Assign view with single-tree checkboxes and publish flow.

Scope:

  • Stage selector (above category tabs)
  • Filter toggle (All / On stage / Not on stage) based on the published stage baseline
  • System question auto-assignment info banner: when quantitative data extraction is enabled, system questions (Label/Control) are locked and auto-assigned (with bar_chart icon); when not enabled, system questions can be toggled but auto-include when child questions are assigned
  • Single question tree with checkboxes
  • Two visual dimensions of pending state: change status icon (unchanged/changed/warning) and assignment delta border (added/removed/normal)
  • Indeterminate checkboxes for partial assignment
  • Click to toggle, Shift+click for children
  • Autosave of assignment changes
  • Validation error display with "Fix in Design" links
  • Publish flow (step-by-step wizard):
  • "Review & Publish N changes" primary action in the Assign action bar
  • "Publish Stage" secondary entrypoint from Preview when "With unpublished changes" is active
  • Wizard steps: Review Changes -> Confirm Impact -> [Configure Handling] -> [Resolve Conflicts] -> Review & Publish (Steps 3-4 are conditional -- only shown when questions with annotations are affected)
  • Publish button disabled with reasons when validation errors exist
  • "Publish all stages" in overflow menu

Backwards Compatibility:

  • Behind feature flag
  • Old assign interface still works for unmigrated projects

Dependencies: Phase 4 (shares state management and navigation), Phase 3 (API)

Deliverables:

  • Angular components: assign tree, filter toggle, action bar, review dialog
  • Publish service integration
  • Unit and integration tests

Phase 6: Preview View (Frontend)

Goal: Build the interactive preview with published/with-unpublished-changes toggle.

Scope:

  • Stage selector including "Full Project (all questions)" option
  • Published vs With unpublished changes toggle (radio buttons)
  • Interactive annotation form in simulation mode
  • Unpublished-changes highlighting ("published with changes" label, "new" badge)
  • Publish button (secondary location when previewing unpublished changes)
  • Validation error summary

Backwards Compatibility:

  • Behind feature flag
  • Uses same AnnotationFormComponent (or v2 replacement)

Dependencies: Phase 5 (publish flow), Phase 4 (navigation)

Deliverables:

  • Angular components: preview container, mode toggle, simulation wrapper
  • Unit tests

Phase 7: Annotation Form v2

Goal: Full rewrite of the annotation form with collapsible unit tiles, signal forms, and performance optimisation.

Approach: This is a complete rewrite, not an incremental update of the existing 943-line AnnotationFormComponent. The existing implementation uses RxState and reactive forms; signal forms require a clean foundation.

Scope:

  • Mini card unit selector (within category tabs)
  • Select-to-show workspace pattern
  • Multi-expand with expand all/close all
  • Full-screen dialog mode per unit
  • Overflow menu on cards and panels (hide, edit label, duplicate, delete)
  • Conditional question nesting with left border indication
  • Three-state completion indicators
  • Pagination for 20+ units
  • Signal forms with schema validation (zoneless, per-unit form state)
  • Rewritten form controls — all question controls (text input, dropdown, checkbox, radio, checklist, autocomplete) rebuilt as signal-form-native, zoneless standalone components. Do not reuse existing reactive-form-based controls.
  • Per-unit form state (isolated signal form groups, no shared reactive form tree)

Performance Strategy:

Performance is critical for the annotation form — annotators work with this form continuously, and sluggishness directly impacts research throughput.

Technique Where Impact
CDK Virtual Scrolling Question list within each unit panel Only renders visible question rows. Critical for units with 50+ questions.
Lazy unit rendering Unit workspace panels Collapsed/hidden units render zero form fields. Only expanded units create form controls in the DOM.
Unit pagination Unit card selector For 20+ units, paginate cards (10 per page). Only current page's cards + open panels in DOM.
Category-level lazy loading Category tab switching Only build form data for the active category. Other categories' forms are not created until selected.
Signal-based conditional rendering Conditional questions Use computed() signals for visibility. Hidden conditional questions are removed from DOM entirely (not just hidden via CSS).
Debounced validation Per-unit form validation Validate on blur or after 300ms of inactivity, not on every keystroke.
OnPush everywhere All annotation form components Combined with signal forms, this eliminates unnecessary change detection cycles.

Backwards Compatibility:

  • This is the annotator-facing form -- changes affect all users, not just admins
  • Must support BOTH old-model questions (unmigrated) and new-model questions (migrated)
  • Consider a separate feature flag for annotation form changes
  • Existing annotations must continue to work

Dependencies: Phase 3 (API for version-aware question loading), Phase 4 (for Preview integration)

Deliverables:

  • Rewritten annotation form components (all signal-form-native, zoneless)
  • Rewritten form controls (text, dropdown, checkbox, radio, checklist, autocomplete)
  • Unit tile components with virtual scrolling
  • Performance testing with large projects (2000+ questions, 50+ units)
  • Performance benchmarks: 50 units loads in <2s, 200 questions in active unit scrolls at 60fps

Phase 8: Admin Decision Framework

Goal: Implement the full annotation impact handling when SQS changes affect active annotations. This phase lifts the Phase 1-7 mitigation that blocks publishing new AQ versions to stages with annotation sessions.

Phase 8 is decomposed into 5 sub-phases that build on each other:

Phase 8.1: Annotation Impact Assessment Engine

Goal: Backend engine to compute annotation impact on demand.

Scope:

  • Aggregate annotation counts per question (project-wide, not per-stage)
  • Compute answer distribution (group latest AVs by answer value)
  • Compute session counts (completed vs in-progress, by answer value, by AQ version)
  • On-demand computation at publish wizard time (not pre-computed)
  • API endpoints for impact data retrieval

Dependencies: Phase 3 (API)

Deliverables:

  • AnnotationImpactService with count, distribution, and session aggregation
  • API endpoints for impact queries
  • Unit tests with realistic annotation data

Phase 8.2: Impact & Mapping Configuration

Goal: Design-time impact awareness in the properties panel.

Scope:

  • Impact & Mapping section in Design view properties panel (auto-expands when editing published AQ with annotations)
  • DraftPublishDecision on AQ.draft.draftPublishDecision for edits to existing published questions
  • DraftPublishDecision on DraftQuestion.draftPublishDecision for replacement drafts with lineage (replacesAnnotationQuestionId != null)
  • Classification radio: "Does not affect existing answers" / "May affect existing answers"
  • Handling options: keep/map/re-answer (conditional on "May affect")
  • Per-option answer mapping configuration
  • Change note field
  • Annotation count and answer distribution display from Phase 8.1

Dependencies: Phase 8.1, Phase 4 (Design view)

Deliverables:

  • ImpactMappingPanelComponent in properties panel
  • DraftPublishDecision persistence via autosave
  • Unit tests for classification and mapping configuration

Phase 8.3: Publish Wizard with Impact

Goal: Multi-step publish wizard that incorporates annotation impact decisions.

Scope:

  • 5-step wizard: Review Changes -> Confirm Impact -> [Configure Handling] -> [Resolve Conflicts] -> Review & Publish
  • Step 1: Orientation -- grouped changes (your edits, cross-stage updates, new assignments, removals), first-publish info card
  • Step 2: Per-question impact confirmation -- pre-populated from Design-time DraftPublishDecisions, auto-confirmed for zero-annotation questions, objectively invalid annotations pre-expanded
  • Step 3 (conditional): Answer handling per-question with answer distribution, mapping configuration, separate sections for in-progress and completed sessions
  • Step 4 (conditional): Multi-question conflict detection and resolution (sessions where per-question decisions conflict)
  • Step 5: Session impact summary (computed effective state), final review, change notes
  • Concurrency re-validation at publish time: snapshot annotation state at wizard open, re-validate before commit, abort and refresh if state changed (admin decisions preserved)
  • Two-track breaking change model: Track 1 (subjectively breaking -- explicit admin confirmation), Track 2 (objectively invalid -- system-required decisions)

Dependencies: Phase 8.2, Phase 8.1, Phase 5 (Assign view publish flow)

Deliverables:

  • PublishWizardComponent with step components
  • Concurrency re-validation service
  • Two-track change classification logic
  • Integration tests for wizard flow and concurrency

Phase 8.4: Session Transition Engine

Goal: Backend engine to transition annotation session versions per admin PublishDecision.

Scope:

  • Transition ASVs created atomically at publish time per PublishDecision
  • Completed session handling: leave as-is, map (keep complete), map + reopen for review, reopen for re-answer
  • In-progress session handling: don't update, map answers, re-answer
  • Per-option answer mapping execution
  • Multi-question conflict detection: sessions where per-question decisions create inconsistent state
  • Conflict resolution enforcement (all conflicts must be resolved before publish)
  • Consistency invariant: no session version can contain an annotation with an invalid answer for its AQ version
  • Completed session filtering by answer value (admin can target specific answer values)
  • PublishDecision promoted from DraftPublishDecision to immutable record on AQVersion.publishDecisions
  • publishDecisionSummary written to SQSVersion
  • VersionAudit cascading provenance on all created versions (AQVersion -> PQSVersion -> SQSVersion -> ASV -> AV)

Dependencies: Phase 8.3, Phase 3 (API)

Deliverables:

  • SessionTransitionService with atomic ASV creation
  • PublishDecision promotion and persistence
  • Conflict detection and invariant enforcement
  • Integration tests with realistic multi-question, multi-session scenarios

Phase 8.5: Annotator-Facing Version Transition

Goal: Annotator experience when their sessions are transitioned by admin publish.

Scope:

  • Dismissible inline alerts (not checkboxes) on questions requiring review -- dismissing removes the alert entirely
  • Re-answer flow: answer cleared, previous answer in history, annotator must provide new response
  • "Use previous answer" button -- only available if previous answer is still valid in new version
  • Pre-filled mapped answers when admin configured mapping + review
  • Session cannot be marked complete until all flagged questions are dismissed or answered
  • Annotator history access: answer history per question (all AVs), session history (all ASVs)

Dependencies: Phase 8.4, Phase 7 (Annotation form v2)

Deliverables:

  • DismissibleReviewAlertComponent and PreviousAnswerPopoverComponent
  • Re-answer and review flows in annotation form
  • Session completion validation
  • Integration tests for annotator transition experience

Backwards Compatibility (all sub-phases):

  • Only applies to projects using the new versioning model
  • Unmigrated projects don't have versioned questions, so this doesn't trigger
  • Phases 1-7 mitigation (block publish to annotated stages) is removed once Phase 8 is complete

Phase 9: Production Migration & Rollout

Goal: Migrate all production projects and enable the new system.

Scope:

  • Run migration (Phase 2) on all production projects
  • Validate migration results
  • Enable feature flag for staged rollout:
  • Stage 1: Internal/test projects
  • Stage 2: Small projects with few questions
  • Stage 3: All projects
  • Monitor for issues
  • Decommission legacy code after stable period

Backwards Compatibility:

  • Rollback possible per-project via migration service
  • Feature flag can be disabled to revert to legacy UI
  • Old API endpoints remain available as fallback

Dependencies: All previous phases

Deliverables:

  • Migration execution records
  • Monitoring dashboards
  • Rollback runbook
  • Legacy code removal plan

4. Migration Strategy

Data Migration Approach

The migration is per-project and additive (new data alongside old, not replacing).

BEFORE MIGRATION:
  pmProject.AnnotationQuestions = [embedded AQ objects]
  pmProject.Stages[].AnnotationQuestions = HashSet<Guid>
  pmStudy.Annotations[].QuestionId = GUID (no version)

AFTER MIGRATION:
  pmProject.AnnotationQuestions = [still there, untouched for rollback]
  pmProject.ProjectQuestionSet = { versions: [PQS v1] }
  pmProject.Stages[].StageQuestionSet = { versions: [SQS v1 per stage] }
  pmAnnotationQuestion = [new collection, one doc per question with v1 in version history]
  pmStudy.Annotations[].QuestionId = GUID (unchanged)
  pmStudy.Annotations[].QuestionVersionId = GUID (NEW, points to AQVersion._id)

Migration Steps Per Project

  1. Extract: Read all AnnotationQuestions from pmProject (both custom and system)
  2. Create AQ documents: For each question, create an AnnotationQuestionV2 document in pmAnnotationQuestion with the question's current content as AQVersion v1 in the publishedVersions array. Preserve the original GUID as _id.
  3. Handle system questions: System questions are dynamically generated and not persisted. Create versioned copies in pmAnnotationQuestion with the same hardcoded GUIDs, marking them as system: true.
  4. Create PQS v1: Create pmProject.ProjectQuestionSet if missing, then append a PQSVersion to pmProject.ProjectQuestionSet.versions referencing all questions at their v1 AQVersion IDs.
  5. Create SQS v1 per stage: For each stage with assigned questions, create StageQuestionSet if missing, then append an SQSVersion to stage.StageQuestionSet.versions referencing PQS v1 and the ordered question subset.
  6. Backfill annotations: Add QuestionVersionId to existing annotations in pmStudy, pointing to the corresponding AQVersion v1 _id.
  7. Validate: Compare old embedded questions against new pmAnnotationQuestion documents for data consistency.
  8. Mark project as migrated: Set a migration flag on the Project document.

Handling Production Violations

The 32 known violations (invalid parent-child relationships from 2017-2019) must be repaired during migration so the new model is consistent with the system-question hierarchy:

  • Fix 23 zero-annotation violations directly by setting the correct system-question parent before freezing them as versioned questions
  • Fix 9 annotated Experiment violations via a deterministic legacy-root migration plan that reparents the question definition to Experiment Label while preserving existing answers through per-study unit mapping, dry-run validation, and post-migration reconciliation checks
  • Record every repair in migration execution metadata for audit and rollback analysis
  • Prevent new violations via the validation rules

Rollback

Per-project rollback: 1. Delete entries from pmAnnotationQuestion for this project 2. Remove ProjectQuestionSet object from Project document 3. Remove StageQuestionSet objects from Stage documents 4. Remove QuestionVersionId from annotations in pmStudy 5. Clear migration flag on Project document 6. Original embedded AnnotationQuestions are untouched throughout — they serve as the rollback source


5. Backwards Compatibility

Dual-Model Support

During the transition period, the system supports both models simultaneously:

Aspect Unmigrated Project Migrated Project
Question storage Embedded in pmProject pmAnnotationQuestion collection (+ PQS/SQS on Project/Stage)
Stage assignment HashSet on Stage SQS versions on Stage (referencing PQS on Project)
Annotation reference QuestionId only QuestionId + QuestionVersionId
Frontend Legacy QM (v1) New QM (v2) behind feature flag
API Existing endpoints New v2 endpoints

What Must NOT Break

  1. Annotation form must work for both migrated and unmigrated projects
  2. Data export must produce the same output regardless of model
  3. Screening is unaffected (doesn't use annotation questions)
  4. MassTransit consumers must handle events from both models
  5. Existing annotations must remain accessible and valid

Feature Flag Strategy

newQuestionManagement = false (default)
  → Legacy QM UI
  → Old API endpoints
  → Questions embedded in Project

newQuestionManagement = true
  → New QM UI (Design/Assign/Preview in sidebar)
  → New v2 API endpoints
  → Questions in pmAnnotationQuestion collection
  → PQS/SQS versioning active

annotationFormV2 = false (default, separate flag)
  → Current annotation form
  → No unit tiles

annotationFormV2 = true
  → New annotation form with unit tiles
  → Works with both old and new question models

Using two separate feature flags allows the annotation form to be updated independently of the question management UI. The annotation form affects all users (annotators), while QM only affects admins.


6. Risk Register

Risk Likelihood Impact Mitigation
Migration corrupts question data Low Critical Dry-run mode, per-project rollback, validation service
Performance regression with 2000+ questions Medium High Virtual scrolling, focus mode, lazy category loading. Test with real large project data.
Annotation form breaks for existing annotations Low Critical Separate feature flag, extensive integration testing with production data snapshot
System question versioning conflicts with hardcoded GUIDs Medium Medium Preserve GUIDs as identity, version content separately. System questions get special handling in migration.
Admin Decision Framework complexity High Medium Stub initially, implement incrementally. Phase 8 is deferrable if publish flow works for stages without active annotations.
Cross-question validation performance Medium Medium Validate incrementally on change, not full-tree on every keystroke. Cache validation results.
32 production violations cause migration failures Low Medium Tolerate in migration, log for audit, prevent new ones
Angular Signal Forms maturity Medium Medium Experimental in Angular 21 but stabilising rapidly. QM v2 is behind a feature flag, making it low-risk to adopt early. Monitor Angular releases. If API changes before stable, refactor is scoped to QM v2 only.
Publish wizard state management complexity High Medium Multi-step SignalStore, extensive step-transition testing
Concurrency between annotation submission and admin publish Medium High Optimistic concurrency with re-validation at publish time

7. Angular Conventions

Modern Angular APIs are mandatory. QM v2 is a greenfield feature behind a feature flag — the ideal context to adopt Angular's latest APIs without risk to the rest of the application. Every component, service, and form must use the newest stable (or near-stable) Angular patterns. Do not fall back to older patterns for convenience, even if those patterns are prevalent elsewhere in the codebase. The rest of the codebase will eventually follow QM v2's lead.

Established conventions from codebase analysis (Angular 21.2.4, ngrx 21.0.1, TypeScript 5.9.3 strict mode), with forward-looking overrides for QM v2:

Rewrite Policy

Rewrite, don't adapt. QM v2 is a full rebuild behind a feature flag. Do not attempt to incrementally modify existing v1 components or wrap them with signal adapters. Instead:

  • Rewrite all QM components from scratch using signal forms, zoneless change detection, and modern Angular patterns
  • Rewrite shared form controls (dropdowns, checkboxes, text inputs, autocomplete) as signal-form-native components. The existing form controls are built around reactive forms and will not integrate cleanly with signal forms. New QM-specific form controls should be standalone, zoneless, and designed for signal form binding.
  • Rewrite the annotation form (Phase 7) rather than wrapping the existing 943-line AnnotationFormComponent. The existing implementation uses RxState and reactive forms — retrofitting signal forms into it would produce worse code than a clean rewrite.
  • Salvage logic, not components. Business logic (validation rules, conditional question resolution, tree traversal algorithms) can be extracted as pure functions and reused. UI components should be freshly built.

The feature flag ensures the old implementation continues to work for users while the new one is developed and tested.

Template Syntax

Use new control flow exclusively (@if, @for, @switch). The codebase has 98.8% adoption (1,048 occurrences across 148 files vs 13 legacy *ngIf/*ngFor in 6 files). Zero tolerance for structural directives in new code.

Dependency Injection

Use the inject() function exclusively. No constructor injection in new code.

export class DesignComponent {
  private store = inject(Store);
  private router = inject(Router);
  private designStore = inject(DesignStore);
}

Components

All components are standalone (implicit in Angular 21). Declare all imports in the @Component decorator. No NgModules.

Change Detection

All QM v2 components use ChangeDetectionStrategy.OnPush — no exceptions. Combined with signal-based inputs/outputs and signal forms, this ensures zoneless-ready change detection. See the "Change Detection and Zone.js" section below for full requirements.

State Management

Scope Pattern Example
Feature-local state ngrx SignalStore DesignStore, AssignStore (already exist)
Global entity state Classic ngrx Store + Reducers annotationQuestions.feature.ts (already exists)
Composable state logic Signal store features withAnnotationQuestionsFeature(), withAnnotationQuestionTreeFeature()

SignalStore is the established pattern for new feature state. Classic Store is used for global entity CRUD.

Forms

Use Angular Signal Forms (SignalForm, SignalFormGroup, SignalFormControl) exclusively for all new QM v2 code. Signal forms are experimental in Angular 21 but will stabilise imminently. QM v2 is a greenfield rebuild behind a feature flag — the ideal place to adopt the new API ahead of the rest of the codebase.

Do NOT use:

  • Reactive Forms (FormGroup, FormBuilder, FormControl) — legacy pattern, used elsewhere in the codebase but not for new QM code
  • Template-driven forms (ngModel) — legacy
  • The existing QM v2 Design tab uses template-driven forms with Vest — this will be replaced entirely

Signal forms integrate natively with signals, eliminating the need for valueChanges observables and manual change detection triggers.

Validation

Use the built-in schema validation provided by Angular Signal Forms. Do NOT use Vest for QM v2 code, even though it is widely used elsewhere in the codebase (790 occurrences across 88 files).

Rationale: Signal forms provide co-located, schema-based validation that integrates directly with the signal reactivity model. Using Vest would require bridging between two reactive systems (Vest's suite model and signal form state), adding unnecessary complexity.

For the 16 cross-question validation rules (AQ001-AQ016), implement as pure functions consumed by the QuestionValidationService, not as Vest suites. These rules validate relationships between questions (parent-child integrity, option coherence, etc.) and operate at the domain level, not the form field level.

Styling

SCSS exclusively (.component.scss). Material Design components from @angular/material.

Change Detection and Zone.js

All new QM v2 components MUST be zoneless using ChangeDetectionStrategy.OnPush and signal-based inputs/outputs. The broader codebase still uses Zone.js, but QM v2 components should be written to work without it.

Requirements for zoneless components:

  • Use ChangeDetectionStrategy.OnPush on every component
  • Use input() / output() signal-based APIs (not @Input() / @Output() decorators)
  • Use computed() and effect() for derived state
  • Never rely on Zone.js patched async operations for change detection
  • Use inject(ChangeDetectorRef) only as a last resort for third-party library interop

This ensures QM v2 code is ready for the project-wide zoneless migration and performs optimally from day one.


8. Component Architecture

Phase 4: Design View Components

QuestionManagementComponent (route: /project/:id/admin/questions)
  |-- Sidebar nav integration (Questions > Design, Assign, Preview)
  |-- Shared: CategoryTabBarComponent (horizontal tabs, shared across all views)
  |
  |-- DesignComponent (route: /questions/design)
  |     |-- DesignStore (SignalStore -- feature state)
  |     |     |-- questions: Signal<DraftQuestion[] | AnnotationQuestion[]>
  |     |     |-- selectedQuestionId: Signal<string | null>
  |     |     |-- selectedCategory: Signal<string>
  |     |     |-- isDirty: Signal<Map<string, boolean>> (per-question)
  |     |     |-- validationErrors: Signal<Map<string, ValidationError[]>>
  |     |     |-- autosaveStatus: Signal<'idle' | 'saving' | 'saved' | 'error'>
  |     |     |-- undoStack / redoStack (session-level)
  |     |
  |     |-- BreadcrumbBarComponent (always visible, ancestor path)
  |     |-- DesignSplitPanelComponent (resizable grid: tree | properties)
  |     |     |-- QuestionTreeComponent (left panel)
  |     |     |     |-- QuestionNodeComponent (recursive, per question)
  |     |     |     |     |-- ChevronComponent (expand/collapse)
  |     |     |     |     |-- HoverActionsComponent (+, drag handle)
  |     |     |     |     |-- StatusIconComponent (draft/published/changed)
  |     |     |     |-- TreeConnectorDirective (L-shaped SVG lines)
  |     |     |     |-- FocusModeService (depth truncation + re-rooting)
  |     |     |     |-- DragDropService (CDK drag-drop, sibling constraint)
  |     |     |
  |     |     |-- PropertiesPanelComponent (right panel)
  |     |           |-- ConditionalityCardComponent (parent + Show When)
  |     |           |-- QuestionContentFieldsComponent (text, help text)
  |     |           |-- TypeSelectorComponent (control type, data type icons)
  |     |           |-- OptionsEditorComponent (inline list + Handsontable dialog)
  |     |           |-- BehaviourTogglesComponent (required, multiple, etc.)
  |     |           |-- ImpactMappingPanelComponent (auto-expands for published AQ with annotations; DraftPublishDecision config)
  |     |           |-- VersionHistoryComponent (timeline + diff)
  |     |           |-- AutosaveStatusComponent (saving/saved/error)
  |     |           |-- ActionButtonsComponent (duplicate, delete)
  |     |           |-- ReplaceWithNewVersionDialogComponent (MAT_DIALOG, creates replacement drafts with lineage)
  |     |
  |     |-- InlinePropertiesComponent (alternative to split panel, expands below node)

Phase 5: Assign View Components

AssignComponent (route: /questions/assign)
  |-- AssignStore (SignalStore)
  |     |-- stageId: Signal<string>
  |     |-- filterMode: Signal<'all' | 'live' | 'not-live'>
  |     |-- checkboxStates: Signal<Map<string, boolean>>
  |     |-- baselineStates: Signal<Map<string, boolean>> (for change detection)
  |     |-- pendingChanges: computed (diff checkbox vs baseline)
  |
  |-- StageSelector (dropdown)
  |-- FilterToggleComponent (All / On stage / Not on stage)
  |-- InfoBannerComponent (quantitative data extraction context, `bar_chart` icon)
  |-- AssignTreeComponent (full-width, checkboxes)
  |     |-- AssignNodeComponent (checkbox + type icon + text; two visual dimensions: change status icon + assignment delta border)
  |-- AssignActionBarComponent (sticky bottom: status + discard + publish)
  |-- PublishWizardComponent (MAT_DIALOG, multi-step)
        |-- ReviewStepComponent (Step 1: grouped changes, first-publish info card)
        |-- ConfirmImpactStepComponent (Step 2: per-question impact confirmation)
        |-- ConfigureHandlingStepComponent (Step 3, conditional: answer handling, mapping, session scope)
        |-- ResolveConflictsStepComponent (Step 4, conditional: multi-question conflict resolution)
        |-- ReviewPublishStepComponent (Step 5: session impact summary, final review, publish)

Phase 6: Preview View Components

PreviewComponent (route: /questions/preview)
  |-- StageSelector
  |-- PreviewModeToggle (Published / With Changes)
  |-- SimulationBannerComponent
  |-- AnnotationFormWrapperComponent (simulation mode, no persistence)

Phase 7: Annotation Form v2 Components

All components are rewritten from scratch — signal-form-native, zoneless (OnPush + signal input()/output()), standalone.

AnnotationFormV2Component (REPLACES AnnotationFormComponent — full rewrite)
  |-- AnnotationFormStore (SignalStore, per-unit state)
  |     |-- units: Signal<Unit[]>
  |     |-- openUnitIds: Signal<Set<string>>
  |     |-- formStates: Signal<Map<string, UnitFormState>> (per-unit signal forms)
  |     |-- completionStates: computed (derived from form validity signals)
  |
  |-- UnitCardSelectorComponent (horizontal mini cards + [+] add)
  |     |-- UnitCardComponent (name + completion indicator + overflow)
  |     |-- UnitPaginationComponent (for 20+ units)
  |
  |-- UnitWorkspaceComponent (expanded panels below cards)
  |     |-- UnitPanelComponent (per unit, collapsible, lazy-rendered)
  |     |     |-- UnitHeaderComponent (name + actions: expand, copy, delete, overflow)
  |     |     |-- QuestionListComponent (CDK virtual scroll viewport for 50+ questions)
  |     |     |     |-- QuestionRowComponent (per question, signal form bound)
  |     |     |     |     |-- BranchNumberComponent (flat sequential)
  |     |     |     |     |-- QuestionControlComponent (delegates to control below)
  |     |     |     |     |-- CommentsFieldComponent (optional, signal form)
  |     |     |     |     |-- ConditionalIndicatorComponent (indent + icon)
  |     |
  |     |-- UnitFullscreenDialogComponent (MAT_DIALOG, 90vw)
  |
  |-- CompletionIndicatorComponent (three-state: check/partial/empty)
  |-- DismissibleReviewAlertComponent (inline alert for version transitions, dismissible)
  |-- PreviousAnswerPopoverComponent (shows previous answer, "Use previous answer" when valid)

Rewritten Form Controls (signal-form-native, zoneless, standalone):
  |-- SyrfTextInputComponent        (replaces existing text input)
  |-- SyrfDropdownComponent         (replaces existing dropdown)
  |-- SyrfCheckboxComponent         (replaces existing checkbox)
  |-- SyrfRadioGroupComponent       (replaces existing radio)
  |-- SyrfChecklistComponent        (replaces existing checklist)
  |-- SyrfAutocompleteComponent     (replaces existing autocomplete)
  |
  Each control:
    - Implements ControlValueAccessor for signal forms
    - Uses signal input()/output() APIs
    - OnPush change detection
    - Standalone (no module dependencies)
    - Schema validation integration via signal form binding

Shared Services

AutosaveService
  |-- Debounced save (200ms)
  |-- IndexedDB write-through queue
  |-- Retry with exponential backoff
  |-- Save status signal

ConnectionStateService
  |-- SignalR state monitoring
  |-- API health tracking
  |-- Network event listening
  |-- effective: Signal<'connected' | 'degraded' | 'offline'>

QuestionValidationService
  |-- Cross-question validation (AQ001-AQ016)
  |-- Incremental validation on change
  |-- Cached results per question

PublishService
  |-- PQS/SQS version creation
  |-- Diff generation (field-level)
  |-- Change reason collection
  |-- Update available detection

9. Undo/Redo Architecture

Approach: Client-Side Command Stack

Undo/redo is implemented as a client-side command history on the DesignStore. This is the simplest approach that meets the requirements (session-level, resets after publish or navigation).

Design

interface UndoableAction {
  type: string;                     // e.g., 'updateQuestionText', 'reorderQuestion', 'deleteQuestion'
  questionId: string;
  before: Partial<QuestionState>;   // snapshot of affected fields before change
  after: Partial<QuestionState>;    // snapshot of affected fields after change
  timestamp: number;
}

// In DesignStore
withState({
  undoStack: [] as UndoableAction[],   // most recent at end
  redoStack: [] as UndoableAction[],   // cleared on new action
  maxUndoDepth: 50,                    // prevents unbounded growth
})

Behaviour

Trigger Result
User edits a field Push action to undoStack, clear redoStack, autosave the after state
Ctrl+Z Pop from undoStack, push to redoStack, apply before state, autosave
Ctrl+Shift+Z / Ctrl+Y Pop from redoStack, push to undoStack, apply after state, autosave
Publish Clear both stacks (published state is the new baseline)
Navigate away from Design Clear both stacks (stacks are session-scoped)
Drag-and-drop reorder Single action capturing the full reorder (before/after positions)
Delete question Action captures full question state for potential restoration
Batch operations Single compound action (e.g., "delete question + cascade children")

What Is NOT Undoable

  • Category assignment (structural, done at creation time)
  • Publishing (irreversible by design)
  • Stage assignment changes (Assign view, no undo -- use Discard instead)

Why Client-Side, Not Server-Side

  • Simplicity: No server-side versioning infrastructure needed for undo
  • Speed: Instant undo with no network round-trip
  • Isolation: Each session has its own stack; no multi-user conflict
  • Autosave interaction: Undo applies the before state and autosaves it. The server always has the latest state; undo is just "make a new edit that happens to restore previous values."
  • Server-side safety net: Draft snapshots (GFS retention) provide recovery for longer-term rollback beyond the session scope

10. Unmerged Branch Triage

Triage of 17 remote branches related to Question Management. Decision: which to merge, salvage for reference, or skip.

Summary

Action Count Rationale
ACTIVE 1 feat/question-management-v2 (this branch)
SALVAGE 3 Read for UX concepts, do not merge directly
SKIP 11 Superseded by merged PRs or too old
DELETE 2 Already fully merged into main (stale remote refs)

Branches to Salvage (Reference Only)

These branches contain useful UX concepts but use older Angular patterns incompatible with v2. Use as reference during implementation, do not merge.

Branch Key Concepts to Salvage Last Commit
QMChildQuestionVisualize (PR #2387, OPEN) Child validation indicators, flat assign tree, sticky headers 2026-03-10
QMPDesignPreviewChanges (PR #1676, CLOSED) System question visualization, tree node icons, collapse/expand, tooltips 2024-11-11
QMAssignDirtyCheck (PR #1662, CLOSED) Unsaved-changes guard pattern 2024-08-29

Action: Close PR #2387 and PR #2460 (empty placeholder). Reference these branches during Phase 4-5 implementation.

Branches to Delete (Already in Main)

Branch Notes
QMDesignDragDrop 0 commits ahead, fully merged
QuestionMangementEditRefactor 0 commits ahead, fully merged

Branches to Skip (11 total)

All superseded by merged PRs, docs-only, or too old (2021-2024) to be useful. Includes: feat/question-management, feat/annotation-question-mgmt-reconciliation, QDWizards, QMAssignPageFix, QuestionDesignEditForm, QuestionManagementPreviewPage, QMPreviewPage, feature/hybridQuestionViewing, QuestionDesignEdit, QuestionEditingHybridMode, EditQuestionRearrange.


11. Acceptance Criteria

Quantifiable success criteria per phase. All phases require >80% test coverage for new code.

Phase 1: Data Model Foundation

Criterion Target How to Verify
Domain entities compile and pass type checks All entities defined in C# with unit tests dotnet test
pmAnnotationQuestion collection created with index Index on projectId exists MongoDB listIndexes
Repository CRUD operations work Insert, read, update operations pass Integration tests against test database
Existing functionality unaffected All existing tests pass Full test suite green
New fields on Project/Stage are default empty No migration needed Manual verification on staging
DraftPublishDecision and VersionAudit serialize/deserialize correctly Round-trip through MongoDB Unit tests
replacesAnnotationQuestionId populated on replacement DraftQuestions Field set when "Replace with new version" creates drafts Unit test
GFS snapshot creation Son snapshots created every 15 min of active editing Integration test with simulated edit activity
GFS tier promotion Son → Father at 24h boundary, Father → Grandfather at 7d boundary Unit test with mocked clock advancing through tier boundaries
GFS demotion/pruning Sons older than 24h that weren't promoted are deleted; Fathers older than 7d likewise Unit test with pre-seeded snapshots at various ages
GFS cap enforcement Total snapshots per project never exceeds hard cap (50) Unit test: seed 50 snapshots, create one more, verify oldest pruned
Snapshot deduplication Unchanged questions reference previous snapshot, not duplicate content Unit test: create two snapshots with no edits between, verify second uses referencesSnapshotId
Snapshot restore (full) Restoring a snapshot replaces all draft state and creates a pre-restore snapshot Integration test: edit → snapshot → edit again → restore → verify state matches snapshot AND pre-restore snapshot exists
Snapshot restore (single question) Restoring one question from a snapshot leaves all other draft questions untouched Integration test: edit two questions → snapshot → edit both again → restore one → verify other is unchanged

Phase 2: Migration Infrastructure

Criterion Target How to Verify
Migration completes without errors on prod snapshot 0 failures on local MCP database Dry-run against syrftest snapshot
All 32 known violations tolerated and logged 32 warnings logged, 0 errors Migration log output
Rollback restores original state exactly Diff between pre/post rollback = 0 Validation service comparison
Migration time per project <30 seconds for largest project Timed run against prod snapshot
Question count matches after migration pmAnnotationQuestion.count == Project.AnnotationQuestions.length Validation query
Annotation backfill complete All annotations have QuestionVersionId db.pmStudy.find({"Annotations.QuestionVersionId": null}).count() == 0

Phase 3: Backend API

Criterion Target How to Verify
All 8 endpoints respond correctly 200/201 on valid requests, 400/404 on invalid Integration tests
Publish creates correct PQS + SQS + AQVersions Version chain integrity verified Integration tests with assertions
Cross-question validation catches all 16 rules AQ001-AQ016 covered Unit tests per rule
Autosave round-trip <100ms p95 Measured latency Load test or APM
Concurrent saves don't corrupt data Optimistic concurrency rejects stale writes Concurrent integration test
Publish endpoint captures PublishDecision metadata AQVersion.publishDecisions populated, VersionAudit on all created versions Integration test

Phase 4: Design View (Frontend)

Criterion Target How to Verify
Tree renders 200 questions in <500ms First Contentful Paint Performance test (Lighthouse or manual)
Drag-and-drop reorder persists correctly Question order matches after page reload E2E test
Properties panel autosaves within 1s of edit Network tab shows PUT within debounce Manual verification
Focus mode activates at depth 6+ Breadcrumb appears, tree re-roots Manual verification + unit test
Keyboard navigation covers all spec shortcuts All shortcuts from spec work Manual verification checklist
All question states render correctly Draft/Published/Published-with-changes icons shown Visual regression test
Validation errors highlighted on invalid questions Red border + inline error on field Unit test

Phase 5: Assign View (Frontend)

Criterion Target How to Verify
Checkbox cascade: child check selects ancestors Ancestor checkboxes become checked Unit test
Checkbox cascade: parent uncheck deselects descendants Descendant checkboxes clear Unit test
Indeterminate state on partial selection Checkbox shows indeterminate Unit test
Shift+Click selects/deselects subtree All descendants toggled Unit test
Action bar shows correct counts "N of M assigned, N pending" matches state Unit test
Publish dialog shows field-level diffs Draft-change fields visible per question Manual verification
Publish creates new PQS + SQS versions API returns version IDs Integration test
Wizard step validation prevents advancing with incomplete decisions Next button disabled until all confirmations/decisions made Unit test
Concurrency re-validation detects state changes between wizard open and publish Updated draft state aborts publish and refreshes dialog with previous decisions preserved Integration test

Phase 6: Preview View (Frontend)

Criterion Target How to Verify
Published mode shows exact annotator view Matches annotation form rendering Visual comparison
With unpublished changes mode highlights unpublished changes Published-with-changes/new/removed badges visible Manual verification
Simulation mode does not persist data No API calls on form interaction Network tab
Toggle preserves form state Entered data survives toggle switch Manual verification

Phase 7: Annotation Form v2

Criterion Target How to Verify
Unit cards render with completion indicators Three states shown correctly Unit test
20+ units paginate (10 per page) Pagination controls appear Unit test
Collapsed units don't render form fields DOM inspection shows no form elements Performance test
Full-screen dialog shows all fields Dialog contains complete form Manual verification
Per-unit form state is independent Editing unit A doesn't affect unit B Unit test
Conditional questions show/hide based on parent Child appears on parent answer Integration test
Performance: 50 units loads in <2s Page load time Lighthouse
Works with both old and new question models Feature flag off: old model works Dual-model test

Phase 8: Admin Decision Framework

Phase 8.1: Annotation Impact Assessment Engine

Criterion Target How to Verify
Annotation count aggregation correct Counts match manual query Unit test against test data
Answer distribution computed correctly Group by answer value matches Unit test
Session counts (completed vs in-progress) accurate Counts match database state Integration test
Impact computation completes within 2s for large projects p95 latency <2s Load test with 5000+ annotations

Phase 8.2: Impact & Mapping Configuration

Criterion Target How to Verify
Impact & Mapping section auto-expands for published AQ with annotations Section visible when editing AQ with annotation count > 0 Unit test
DraftPublishDecision persists via autosave Round-trip through save/reload Integration test
Classification and handling options render correctly All options available per two-track model Unit test
Answer mapping configuration per-option Per-option mapping saved to DraftPublishDecision Unit test

Phase 8.3: Publish Wizard with Impact

Criterion Target How to Verify
Wizard shows 5 steps with conditional Steps 3-4 Simple publish: Steps 1,2,5. Complex: all 5 Unit test
Design-time DraftPublishDecisions pre-populate wizard Previous decisions loaded and editable Integration test
Concurrency re-validation detects state changes Abort and refresh with preserved decisions when annotations change Integration test
Two-track breaking change model enforced Track 1: explicit confirmation required. Track 2: system-required decisions Unit test
Publish disabled until all decisions confirmed Button state correct per step completion Unit test

Phase 8.4: Session Transition Engine

Criterion Target How to Verify
Transition ASVs created atomically at publish time ASVs exist after publish, none on abort Integration test
Completed session handling options work correctly Leave/map/map+reopen/reopen all produce correct ASV state Integration test per option
Multi-question conflict detection identifies inconsistent sessions Conflicting sessions flagged in Step 4 Unit test with multi-question scenario
Consistency invariant enforced No session version contains annotation with invalid answer for its AQ version Integration test
PublishDecision promoted from draft to immutable on AQVersion AQVersion.publishDecisions populated, AQ.draft.draftPublishDecision cleared Integration test
VersionAudit cascading provenance recorded Audit trail links AQVersion -> PQSVersion -> SQSVersion -> ASV -> AV Integration test

Phase 8.5: Annotator-Facing Version Transition

Criterion Target How to Verify
Dismissible inline alert shown on transitioned questions Alert visible, dismisses on click, persists across page loads Unit test
Re-answer flow clears answer with history preserved Previous answer in AV history, current answer blank Integration test
"Use previous answer" only available when valid Button hidden when previous option removed Unit test
Session cannot complete until all flagged questions handled Completion blocked, unblocked after dismiss/answer Integration test
Annotator history shows all AVs and ASVs Timeline displays version transitions Manual verification

Phase 9: Production Migration & Rollout

Criterion Target How to Verify
All production projects migrated Migration flag set on all projects Database query
Zero data loss during migration Validation service confirms Automated validation
Feature flag enables without errors No console errors, no API 500s Monitoring dashboards
Rollback possible within 1 hour Per-project rollback tested Runbook execution
Legacy code identified for removal Removal plan documented Documentation review

  • Training Rounds — Admin-defined practice/training sessions that reviewers must complete before accessing the full study set for a stage. Independent of QM v2 phases but designed with version awareness (SQS pinning, expected answer versioning).