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¶
- Gap Analysis: PR2398 Plans vs New Spec
- Current State Summary
- Implementation Phases
- Migration Strategy
- Backwards Compatibility
- Risk Register
- Angular Conventions
- Component Architecture
- Undo/Redo Architecture
- Unmerged Branch Triage
- Acceptance Criteria
Work Tracking¶
Implementation is tracked via GitHub Issues and ZenHub:
- Epic: #2488 — Question Management v2 — Full Redesign (ZenHub: Short-Term Goals, Epic type)
| 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:
-
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.
-
pendingChanges becomes the autosave mechanism: PR2398's
pendingChangesbuffer 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 inpendingChanges. The publish step reads frompendingChangesand creates AQVersions. -
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.
-
Validation rules are unchanged: The 16 formal validation rules and the
AnnotationQuestionPlacementValidatorapply 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 |
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¶
- Questions are embedded in pmProject -- extracting them to a separate collection requires careful data migration
- Annotations reference QuestionId only -- no version reference. Adding
questionVersionRefrequires migrating all existing annotations - System questions are generated dynamically -- they don't persist in the database. The versioning model needs to handle this.
- 32 production violations exist -- migration must tolerate these while preventing new ones
- 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
pmDraftSnapshotcollection 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
pmAnnotationQuestioncollection with projectId index - Define C# domain entities:
DraftQuestion,AnnotationQuestion,AQVersion,DraftContent,ProjectQuestionSet,DraftPQS,PQSVersion,QuestionReference,QuestionRef,StageQuestionSet,DraftSQS,SQSVersion,DraftSnapshot,DraftQuestionSnapshot - Define
DraftPublishDecision,PublishDecision, andVersionAuditvalue objects - Extend Project entity with
DraftQuestions,ProjectQuestionSet - Extend Stage entity with
StageQuestionSet - Include
scopeandownerProjectIdfields for future cross-project sharing - Implement autosave (server-side
draftfield on AQ entities, written via debounced API calls) - Add nullable
QuestionVersionIdto Annotation entity model — do NOT migrate existing annotations yet - Implement optimistic concurrency on Study document writes
Backwards Compatibility:
- New
pmAnnotationQuestioncollection 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
AnnotationQuestionV2entities inpmAnnotationQuestion - 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
questionVersionRefon existing annotations (pointing to AQ v1) - Build system question upgrade service:
SystemQuestionUpgradeService - Produces a dry-run diff when
SystemQuestionVersionchanges - 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:
QuestionMigrationServicewith dry-run and rollbackSystemQuestionUpgradeServicewith 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
ProjectControllerquestion 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
replacesAnnotationQuestionIdlineage 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
pendingChangesvia 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_charticon); 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:
AnnotationImpactServicewith 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)
DraftPublishDecisiononAQ.draft.draftPublishDecisionfor edits to existing published questionsDraftPublishDecisiononDraftQuestion.draftPublishDecisionfor 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:
ImpactMappingPanelComponentin properties panelDraftPublishDecisionpersistence 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:
PublishWizardComponentwith 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)
PublishDecisionpromoted fromDraftPublishDecisionto immutable record onAQVersion.publishDecisionspublishDecisionSummarywritten toSQSVersionVersionAuditcascading provenance on all created versions (AQVersion -> PQSVersion -> SQSVersion -> ASV -> AV)
Dependencies: Phase 8.3, Phase 3 (API)
Deliverables:
SessionTransitionServicewith atomic ASV creationPublishDecisionpromotion 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:
DismissibleReviewAlertComponentandPreviousAnswerPopoverComponent- 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¶
- Extract: Read all
AnnotationQuestionsfrompmProject(both custom and system) - Create AQ documents: For each question, create an
AnnotationQuestionV2document inpmAnnotationQuestionwith the question's current content as AQVersion v1 in thepublishedVersionsarray. Preserve the original GUID as_id. - Handle system questions: System questions are dynamically generated and not persisted. Create versioned copies in
pmAnnotationQuestionwith the same hardcoded GUIDs, marking them assystem: true. - Create PQS v1: Create
pmProject.ProjectQuestionSetif missing, then append aPQSVersiontopmProject.ProjectQuestionSet.versionsreferencing all questions at their v1 AQVersion IDs. - Create SQS v1 per stage: For each stage with assigned questions, create
StageQuestionSetif missing, then append anSQSVersiontostage.StageQuestionSet.versionsreferencing PQS v1 and the ordered question subset. - Backfill annotations: Add
QuestionVersionIdto existing annotations inpmStudy, pointing to the corresponding AQVersion v1_id. - Validate: Compare old embedded questions against new
pmAnnotationQuestiondocuments for data consistency. - 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 |
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¶
- Annotation form must work for both migrated and unmigrated projects
- Data export must produce the same output regardless of model
- Screening is unaffected (doesn't use annotation questions)
- MassTransit consumers must handle events from both models
- 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.OnPushon every component - Use
input()/output()signal-based APIs (not@Input()/@Output()decorators) - Use
computed()andeffect()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
beforestate 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 |
Related Features¶
- 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).