Question Management¶
A dedicated admin workspace for creating, editing, organizing, and versioning annotation questions across their full lifecycle -- from draft design through stage activation to controlled version evolution.
Overview¶
Question Management is the project administrator's primary tool for defining what annotators are asked. It provides a three-tab interface (Design, Assign, Preview) where administrators build annotation questions as drafts, assign them to stages, and activate them for use. Once activated, questions follow a versioning model that preserves every change as an immutable snapshot while keeping existing annotations intact.
This feature replaces the current mutable-question model where edits silently rewrite history. Instead, administrators get a clear two-phase lifecycle: free-form drafting followed by controlled, auditable versioning. The result is a question management system that supports protocol evolution without compromising traceability.
Problem Statement¶
SyRF's current question management has three fundamental limitations:
-
Editing locks after first answer. Once any annotator answers a question, the entire question is locked. Administrators cannot fix a typo, add a missing dropdown option, or improve help text without creating an entirely new question -- even when the change is minor and non-breaking.
-
No version history. When a question is changed (before it gets locked), the edit happens in-place. There is no record of what the question looked like before. Past annotations silently change meaning because there is no link between "the question as it was" and "the answer as it was recorded."
-
No stage-level question configuration history. Stages reference questions by ID only, with no record of which questions (at which versions, in what order) were active when annotations were collected. It is impossible to reconstruct the exact annotation form an annotator saw at any point in time.
These limitations prevent SyRF from meeting the traceability and reproducibility standards expected by journals, funders, and regulatory bodies for systematic reviews.
Solution¶
Question Management introduces a structured lifecycle for annotation questions:
-
Draft Questions (DQs) are fully mutable design-time objects. Administrators iterate freely -- changing text, options, types, parent-child relationships -- with no versioning overhead and no downstream impact.
-
Annotation Questions (AQs) are the committed, production entities created when a stage is activated. Their structural identity (data type, parent, group-as-single) is fixed at birth. Content properties (text, options, help text, answer filters) can be edited, but each edit creates a new immutable AQVersion.
-
Question Set Versions (QSVs) are immutable snapshots of which question versions, in what order, are assigned to a stage. Any change to a stage's question assignments creates a new QSV, preserving the complete history of what annotators were asked.
This gives administrators the flexibility to evolve their annotation protocol while giving the system the immutability guarantees it needs for audit trails, reconciliation, and cross-project data aggregation.
Scope¶
In Scope¶
- Draft Question creation and editing in the QM Design tab
- All six question types: boolean, select, checklist, text, numeric, autocomplete
- Question options, help text, and answer option filter configuration
- Parent-child question hierarchy and group-as-single toggle
- Drag-and-drop reordering of questions
- Stage assignment via the QM Assign tab (dual-tree interface)
- Stage activation converting DQs to AQs with initial AQVersions
- Content editing of activated questions with automatic version creation
- pendingChanges buffer for server-side auto-save of in-progress edits
- Question Set and QSV composition with parent integrity enforcement
- Version history viewer with side-by-side diff comparison
- Version badges on question cards
- Admin decision framework for handling active sessions when QSV changes
- Preview tab showing the exact annotation form annotators will see
- System question handling (auto-included, version-managed by system)
- Feature flag (
newQuestionManagement) for controlled rollout - Migration of existing questions to v1 AQVersions
Out of Scope / Future¶
- Cross-project question sharing -- Import-as-reference, fork-to-customize, community catalogue, and organisation-level libraries are v2 features (SHARE-01 through SHARE-06)
- Advanced question reorganization wizards -- Copy, Copy & Disable, Copy & Delete workflows (AQM-02)
- AQV update impact assessment wizard -- Options A/B/C for affected annotations (AQM-01)
- System questions as a CAMARADES-curated QuestionSet -- Remodelling hardcoded system questions (AQM-03)
- Annotation Form v2 -- The rebuilt form with virtual scroll and per-question auto-save is a separate feature (FORM-01 through FORM-08)
- Reconciliation workflow -- Authority determination, reconciliation dashboard, and reconciler UI are separate features (RECON-01 through RECON-17)
User Workflows¶
Creating Draft Questions¶
The project administrator opens the Question Management page and navigates to the Design tab. This is the workspace for building questions before any stage is activated.
Steps:
- Click "Add Question" to create a new Draft Question
- Choose a question type (see Question Types below)
- Enter the question text -- the wording annotators will see
- Configure type-specific settings:
- For select and checklist: add answer options (labels and optional values)
- For autocomplete: define the option source
- For all types: optionally add help text and answer option filters
- Optionally set a parent question to create a conditional child (the child is shown only when specific parent answers are selected)
- Optionally enable "group as single" to combine parent and child questions into a single visual unit on the annotation form
Draft Questions are saved to the project entity. They have no version history, no downstream dependencies, and no restrictions on what can be changed. Every property is fully mutable during the draft phase.
Organizing Questions¶
The Design tab displays questions as a hierarchical tree. Administrators organize questions using:
- Drag-and-drop reordering -- Move questions up and down within the tree to control display order. Drop targets indicate whether the question will be inserted as a sibling (same level) or as a child (nested under another question).
- Parent-child hierarchy -- Drag a question onto another question to make it a child. Child questions inherit conditional display logic from their parent.
- Category grouping -- Questions are grouped by category (e.g., Study Design, Outcomes, Risk of Bias) within the tree.
The tree supports expand/collapse for nested hierarchies and provides visual feedback during drag operations (drop zone highlighting, insertion indicators).
Assigning to Stages¶
The administrator switches to the Assign tab to configure which questions appear in each stage.
Steps:
- Select a target stage from the stage selector (stage must be disabled)
- The interface shows two panels: unassigned questions (left) and assigned questions (right)
- Drag questions from unassigned to assigned, or use the Assign/Unassign buttons
- Reorder assigned questions to set display order
- System questions for data extraction stages are automatically included and cannot be unassigned
Parent integrity is enforced during assignment: if a child question is assigned to a stage, all of its ancestors are automatically included. The administrator sees the question count update as questions are added or removed.
Changes on the Assign tab are saved independently from the Design tab. The system warns the administrator before navigating away with unsaved assignment changes.
Previewing Before Activation¶
The Preview tab renders the exact annotation form that annotators will see for a selected stage. This includes:
- Question rendering in display order
- Conditional logic visualization (parent-child show/hide behavior)
- Correct control types for each question (dropdown, checkbox, text input, etc.)
- Help text displayed alongside questions
- Answer option filters applied
Preview operates on the current state of assigned Draft Questions. It allows the administrator to verify the annotation experience before committing to activation.
Activating a Stage¶
When the administrator is satisfied with the question design and assignment, they enable the stage. Activation triggers the following system operations:
- DQ-to-AQ conversion -- Each Draft Question assigned to the stage is converted to an Annotation Question. The DraftId is replaced with a permanent AqId. The DQ ceases to exist.
- Initial AQVersion creation -- Each new AQ receives its first AQVersion (v1) containing the current text, options, help text, and answer filters.
- Identity property freeze -- The AQ's structural properties (data type, parent question, group-as-single) are fixed at this point and cannot be changed afterward.
- QSV composition -- The system creates the stage's first Question Set Version (QSV) -- an immutable, ordered list of the AQVersionIds assigned to the stage.
- Stage opens for annotation -- The stage becomes available to annotators.
Activation is a one-way operation. Once a stage is activated, its questions follow the versioning model described below.
Editing Activated Questions¶
After activation, the administrator can still modify question content, but changes follow a controlled versioning process.
What can be changed (content properties):
| Property | Description |
|---|---|
| Question text | The wording annotators see |
| Options | Answer choices for select, checklist, and autocomplete types |
| Help text | Guidance shown alongside the question |
| Answer option filters | Conditional option restrictions based on parent answers |
What cannot be changed (identity properties):
| Property | Reason |
|---|---|
| Data type | Changing from boolean to string would invalidate all existing answers |
| Parent question | Changing hierarchy restructures entity subtrees and breaks annotation tree references |
| Group-as-single | Changes the fundamental display structure of parent-child groups |
To change an identity property, the administrator must create a new question.
The editing workflow:
- Open the question in the Design tab -- a version badge shows the current version (e.g., "v2")
- Edit the content properties. Changes are auto-saved to the
pendingChangesbuffer on the AQ entity (server-side, survives browser changes) - When ready, click "Save as New Version" -- the system commits
pendingChangesas a new AQVersion with an incremented version number - The administrator can optionally record a change reason explaining why the edit was made
- The new AQVersion is available for assignment to stages via QSV composition
The pendingChanges buffer allows the administrator to make multiple edits before committing them as a single version. Pending changes do not affect annotators -- only committed AQVersions are referenced by QSVs and shown on annotation forms.
Updating a Stage's Question Set¶
After creating a new AQVersion (or adding/removing questions), the administrator updates the stage's question configuration:
- Open the Assign tab and select the stage
- Modify the assigned questions (add, remove, reorder, or upgrade to a newer AQVersion)
- The system composes a new QSV -- an immutable snapshot of the updated configuration
- If the stage has active annotation sessions under the previous QSV, the administrator is prompted with the Admin Decision Framework
Version History¶
The administrator can view the complete version history for any activated question:
- Select a question in the Design tab
- Open the version history panel
- View a timeline of all versions: version number, who created it, when, and the change reason
- Select any two versions for side-by-side diff comparison
- Diffs highlight exactly what changed: added/removed options, modified text, updated help text
Version history provides the audit trail required for systematic review traceability. Every change to every question is recorded with attribution and timestamp.
Version Badges¶
Each question card in the Design and Assign tabs displays a version badge (e.g., "v3") showing the current version number. This provides at-a-glance awareness of:
- How many times a question has been edited since activation
- Whether a question is on its initial version (v1) or has evolved
- Which version is currently assigned to a given stage (in the Assign tab)
Admin Decision Framework¶
When a stage's QSV changes, the system versions forward by creating new ASVs on affected sessions. The new ASV's shape depends not only on the QSV diff but also on each session's specific answers, because parent-child conditionality means the same QSV change can affect different sessions differently.
See Annotation Versioning — Resolved Question Set and Parent Annotation Condition for the formal definitions referenced below.
Why the QSV diff alone is insufficient¶
A simple AQ-level diff (added, removed, updated) is a necessary starting point but does not fully determine the impact on each session. Parent-child conditionality means an annotation's presence in the new ASV depends on its entire ancestor chain:
- An option removed from a root question may cascade to exclude an entire subtree of child annotations
- A change to an ancestor's answer filters may cause previously-hidden questions to become visible (or vice versa)
- Whether a carried-forward answer is valid depends not just on the AQVersion change but on whether the annotation satisfies the Parent Annotation Condition (PAC) in the new ASV
The system must therefore evaluate each session individually, reconstructing the new ASV top-down from root annotations using PAC.
Breaking change metadata on AQVersions¶
Each AQVersion carries a BreakingChange: boolean flag set by the admin when creating the version. This records the admin's intent about whether the change invalidates existing answers.
Transitivity: When a QSV transition jumps across multiple AQVersions (e.g., v2 → v5), the transition is breaking if any intermediate version was marked breaking. The system computes this automatically.
Contextual breaking: Even non-breaking changes can be breaking for specific sessions. For example, a "non-breaking" option addition is generally safe, but if a different version removes an option that a specific annotator had selected, their answer becomes invalid. The BreakingChange flag is the admin's general classification; the reconstruction algorithm performs actual break detection per-session by checking whether each carried-forward answer remains valid against the new AQVersion.
The admin is warned at version creation time that the flag represents intent, not a guarantee — the system will detect contextual breaks automatically during QSV transitions.
ASV reconstruction algorithm¶
New ASVs are built top-down from root annotations, evaluating PAC at each level of the question tree:
- Sort the new QSV's AQVersions in tree order (roots first, then children by depth)
- For each AQVersion
qin the sorted list: a. Evaluate PAC(q) against the partially-built new AVMap. If PAC fails, skip q and its entire subtree b. If PAC passes, classify the AQ change and determine the AV:
| Classification | Condition | AV in new ASV | Metadata |
|---|---|---|---|
| Carried forward | AQVersion identical in both QSVs, existing AV exists | Existing AV reference | None |
| Carried forward (blank) | AQVersion identical, no existing AV (unanswered) | Blank AV | None |
| Migrated (non-breaking) | AQVersion updated, not marked breaking, answer still valid against new AQVersion | New AV created with carried-forward answer | provenance: {sourceAVId, reason: "non-breaking-update"} |
| Auto-detected breaking | AQVersion updated, not marked breaking, but answer invalid against new AQVersion (e.g., selected option no longer exists) | Blank AV | flags: {needsReanswering: true, reason: "answer-invalid-after-update"} |
| Breaking update | AQVersion updated, marked breaking (or transitive breaking) | Blank AV | flags: {needsReanswering: true, reason: "breaking-update"} |
| New question | AQ newly added to the QSV | Blank AV | flags: {needsAnswering: true, reason: "new-question"} |
| Newly visible | AQ existed in old QSV but was excluded from old ASV because PAC failed (ancestor change now includes it) | Blank AV | flags: {needsAnswering: true, reason: "newly-visible-from-ancestor-change"} |
| Removed | AQ was in old QSV but not in new QSV, or AQ no longer satisfies PAC due to ancestor changes | Excluded from new AVMap | Non-breaking; Annotation and AV still exist, just not referenced |
- After processing all AQVersions, the new ASV's status is determined by the admin's choices (see below) and whether any blanks exist
The new ASV records ResolvedAQVersionIds — the set of AQVersionIds that satisfied PAC — as a materialized field for queryability.
Admin decision flow¶
Rather than choosing a single option, the admin answers a series of questions that provide granular control over the transition:
Step 1 — Scope
- Leave ALL sessions on old QSV? (pin everything, only new sessions use new QSV) → If yes, done
- Leave complete sessions on old QSV? → If yes, only process incomplete sessions; if no, process all
Step 2 — Non-breaking transitions
For sessions where the reconstruction produces no breaking changes (no blank AVs needed):
- Auto-create new ASVs? → If no, leave these sessions on old QSV
- For migrated AVs (non-breaking AQVersion updates): carry forward answers to new AVs, or leave blank for annotator to re-answer?
- Inherit completion status from previous ASV, or mark all as incomplete for review?
Step 3 — Breaking transitions
For sessions where the reconstruction produces breaking changes (one or more blank AVs):
- Update to new QSV anyway? → If yes, create new ASV marked incomplete with breaking AVs flagged. If no, leave on old QSV
Step 4 — Confirmation
The system presents a summary:
- N sessions will get new ASVs (X complete, Y incomplete)
- N sessions will remain on old QSV
- Per-AQ breakdown of carried, migrated, breaking, new, and removed annotations
- For each breaking AV: which sessions are affected and why
The admin reviews and confirms. The decision and all parameters are recorded as part of the QSV assignment metadata for audit purposes.
Question Types¶
| Type | Control | Options Required | Answer Format | Notes |
|---|---|---|---|---|
| Boolean | Toggle / Yes-No | No | true / false |
Simplest type. Used for binary decisions. |
| Select | Dropdown | Yes (2+ options) | Single option value | Annotator picks exactly one option from a list. |
| Checklist | Checkbox group | Yes (2+ options) | Array of option values | Annotator picks zero or more options. |
| Text | Text input | No | Free-text string | For qualitative or unstructured responses. |
| Numeric | Number input | No | Integer or decimal | Accepts integer or decimal values. |
| Autocomplete | Autocomplete input | Yes (option source) | Single or multiple values | Type-ahead search against a defined option list. |
All question types support:
- Help text -- Guidance text shown alongside the question to assist annotators
- Answer option filters -- Conditional logic that restricts which options are available based on the parent question's answer
- Optional/Required -- Whether the annotator must provide an answer
- Parent-child relationships -- Conditional display based on parent answer selection
Data Model¶
Draft Annotation Question (DraftAQ)¶
Lives on the Project aggregate. Fully mutable. No version history.
DraftAQ
DraftId: Guid -- temporary, replaced by AqId on activation
DataType: string -- mutable during draft phase
ParentId: Guid? -- mutable during draft phase
GroupAsSingle: boolean -- mutable during draft phase
Text: string -- mutable
Options: List<AnswerOption> -- mutable
HelpText: string? -- mutable
AnswerFilters: List<AnswerFilter> -- mutable
Category: QuestionCategory -- mutable
DraftAQs are destroyed on activation. They do not survive beyond the draft phase.
Annotation Question (AQ)¶
Lives in the pmAnnotationQuestion collection. Identity properties are immutable after activation. Content properties are versionable through AQVersions.
AnnotationQuestion
Id: Guid -- permanent, assigned at activation
Scope: System | Organisation | Researcher | Project
OwnerId: Guid -- SystemId | OrgId | InvestigatorId | ProjectId
Category: QuestionCategory
DataType: AnswerType -- IDENTITY: immutable after activation
ParentQuestionId: Guid? -- IDENTITY: immutable after activation
GroupAsSingle: boolean -- IDENTITY: immutable after activation
DerivedFrom: QuestionId? -- null if original, source QuestionId if forked
PendingChanges: {...} | null -- mutable auto-save buffer for in-progress edits
CurrentVersionNumber: int
Versions: List<AQVersion> -- append-only
AQVersion¶
Immutable content snapshot. Embedded within the AQ document.
AQVersion
Id: Guid -- AQVersionId, globally unique
VersionNumber: int -- sequential: 1, 2, 3, ...
QuestionText: string
Options: List<AnswerOption>
HelpText: string?
AnswerFilters: List<AnswerFilter>
CreatedAt: DateTime
CreatedBy: Guid
ChangeReason: string?
QuestionSet (QS)¶
Lives in the pmQuestionSet collection. Represents a versioned collection of question versions assigned to a stage.
QuestionSet
Id: Guid -- QuestionSetId, stable across versions
Scope: System | Organisation | Researcher | Project
OwnerId: Guid
Name: string
Description: string?
DerivedFrom: QuestionSetId?
CurrentVersionNumber: int
Versions: List<QuestionSetVersion> -- append-only
QuestionSetVersion (QSV)¶
Immutable snapshot of ordered AQVersionId references. Embedded within the QS document.
QuestionSetVersion
Id: Guid -- QSVId, globally unique
VersionNumber: int
AQVersionIds: OrderedList<Guid> -- the question versions, in display order
CreatedAt: DateTime
CreatedBy: Guid
ChangeReason: string?
Parent integrity constraint: All ancestors (via the ParentQuestionId hierarchy) of any AQVersion in a QSV must also be present in the same QSV. This is enforced at QSV composition time. If a child question is included, the system automatically includes all of its ancestor questions. This means cross-stage question overlap is structural and expected -- a parent question shared between stages will appear in both stages' QSVs.
Property Classification Summary¶
| Category | Properties | Mutability Rule |
|---|---|---|
| Identity | AqId, DataType, ParentQuestionId, GroupAsSingle |
Set at activation, immutable forever |
| Content | Text, Options, HelpText, AnswerFilters |
Versionable via AQVersion. Edited through pendingChanges buffer, committed to create new AQVersion. |
| Derived | CurrentVersionNumber, Versions[] |
Managed by the system |
API¶
Draft Question CRUD¶
| Method | Path | Description |
|---|---|---|
| POST | /api/projects/{projectId}/draft-questions |
Create a new DraftAQ |
| GET | /api/projects/{projectId}/draft-questions |
List all DraftAQs for the project |
| GET | /api/projects/{projectId}/draft-questions/{draftId} |
Get a specific DraftAQ |
| PUT | /api/projects/{projectId}/draft-questions/{draftId} |
Update a DraftAQ (any property) |
| DELETE | /api/projects/{projectId}/draft-questions/{draftId} |
Delete a DraftAQ |
Stage Assignment¶
| Method | Path | Description |
|---|---|---|
| PUT | /api/projects/{projectId}/stages/{stageId}/draft-assignments |
Set which DraftAQs are assigned to a disabled stage |
| GET | /api/projects/{projectId}/stages/{stageId}/draft-assignments |
Get current DraftAQ assignments for a stage |
Stage Activation¶
| Method | Path | Description |
|---|---|---|
| POST | /api/projects/{projectId}/stages/{stageId}/activate |
Activate stage: converts assigned DQs to AQs, creates initial AQVersions and first QSV, enables stage |
Annotation Question Versioning¶
| Method | Path | Description |
|---|---|---|
| GET | /api/projects/{projectId}/questions |
List all AQs for the project |
| GET | /api/projects/{projectId}/questions/{questionId} |
Get AQ with current version |
| PUT | /api/projects/{projectId}/questions/{questionId}/pending |
Update pendingChanges (auto-save buffer) |
| POST | /api/projects/{projectId}/questions/{questionId}/versions |
Commit pendingChanges as a new AQVersion |
| GET | /api/projects/{projectId}/questions/{questionId}/versions |
List all AQVersions for a question |
| GET | /api/projects/{projectId}/questions/{questionId}/versions/{versionNumber} |
Get a specific AQVersion |
Question Set Version Management¶
| Method | Path | Description |
|---|---|---|
| GET | /api/projects/{projectId}/stages/{stageId}/question-config |
Get current QSV for the stage |
| PUT | /api/projects/{projectId}/stages/{stageId}/question-config |
Update stage question configuration (creates new QSV). Includes impact choices if active sessions exist. |
| GET | /api/projects/{projectId}/stages/{stageId}/question-config/history |
Get QSV history for the stage |
Migration Strategy¶
Existing production data is migrated additively during Phase 7. No data is deleted or moved.
Question backfill:
- Each existing
AnnotationQuestionon a project becomes an AQ withCurrentVersionNumber = 1 - The current field values (text, options, type, etc.) are captured as the initial AQVersion (v1) -- this is accurate, since questions have never been versioned before
Scopedefaults toProject,OwnerIddefaults toProjectId
Stage configuration backfill:
- For each stage, the existing
HashSet<Guid> AnnotationQuestionsis converted into an initial QSV (v1) - Question order is derived from existing display order
EffectiveFromis set to the stage creation date
Backward compatibility:
- The existing question management UI continues to work behind the
newQuestionManagementfeature flag - API consumers see the same data structures with additional optional fields
- Rollback is clean: remove the new fields with a
$unsetoperation
Success Criteria¶
| # | Requirement | Acceptance Criterion |
|---|---|---|
| 1 | QM-01 | PA can create Draft Questions in the QM Design tab with all six question types, saved on the project entity |
| 2 | QM-02 | PA can edit DQ text, options, help text, and answer option filters freely while in draft state |
| 3 | QM-03 | PA can assign DQs to a disabled stage via the QM Assign tab using drag-and-drop or button actions |
| 4 | QM-04 | PA can preview assigned DQs on the QM Preview tab, seeing the exact form annotators will use |
| 5 | QM-05 | PA can enable a stage, triggering DQ-to-AQ activation (DQs converted to AQs with initial AQVersions, first QSV created, stage enabled for annotation) |
| 6 | QM-06 | AQ structural properties (parent, data type, group-as-single) are fixed at activation and cannot be modified |
| 7 | QM-07 | AQ content properties (text, options, help text, answer option filters) are versionable -- editing creates a new AQVersion while preserving all previous versions |
| 8 | QM-08 | QuestionSet/QSV model implemented -- immutable snapshots of ordered AQVersionId lists with version history |
| 9 | QM-09 | Parent integrity constraint enforced at QSV composition time (all ancestors of any included AQVersion must also be included) |
| 10 | QM-10 | Project Question Library maintains a flat manifest of AQVersionId references available to the project |
| 11 | QM-11 | PA can view question version history with side-by-side diff comparison between any two versions |
| 12 | QM-12 | PA can create a new AQVersion when editing an activated question via a versioning wizard showing the version badge |
| 13 | QM-13 | Admin decision framework presents options (pin, invalidate, require re-annotation) when a new QSV is assigned to a stage with active sessions |
| 14 | QM-14 | Version badge displayed on each question card showing the current version number |
Related Documents¶
| Document | Relationship |
|---|---|
| Annotation Versioning Design | Authoritative source for DraftAQ lifecycle (D37), property classification (D38), pendingChanges (D40), and QSV composition (D43) |
| Design Decisions | Authoritative reference for all 50 design decisions (D1-D50). Sections 1 and 11 cover the AQ entity model and lifecycle patterns. |
| Product Overview | PO-facing description of the Question Management Platform, including phased delivery (Phases 3 and 5) |
| Annotation Form v2 | Rebuilt annotation form that renders QSV-configured questions with per-question auto-save |
| Reconciliation | Workflow that compares answers collected against specific AQVersions to produce gold-standard records |
| Requirements | Requirements QM-01 through QM-14 with phase traceability |
Implementation Appendices¶
⚠️ Precedence note: The appendices below were merged from the closed PR #2325 branch (created 2026-02-09) to preserve implementation detail not carried forward during spec restructuring. The product-level spec above is authoritative for design intent. Where appendix content contradicts the spec above or the Design Decisions (D1–D50), the higher-precedence document wins. In particular:
- The data model sections below use the original embedded
AnnotationQuestionwith_versionHistoryapproach. The authoritative model uses separatepmAnnotationQuestionandpmQuestionSetcollections with Identity + Immutable Versions (D37–D40). See the Data Model section in the spec above.- The Angular UI component inventory and unmerged PR references remain valuable for implementation — they describe existing codebase assets to extend, not rebuild.
- The API specification endpoints are illustrative. Final API design should follow the patterns established in the spec above.
- The acceptance criteria are comprehensive and should be used as a starting point for Phase 4–6 planning, updated to reflect the current data model.
Existing Implementation Inventory¶
A substantial Question Management v2 implementation already exists in the codebase behind the newQuestionManagement feature flag. This must be the starting point — not a greenfield build.
Feature Flag¶
- Name:
newQuestionManagement - Environment variable:
SYRF__FeatureFlags__NewQuestionManagement - Helm value:
featureFlags.newQuestionManagement - Default:
false - Definition:
src/services/web/src/app/generated/feature-flags.types.ts - Selector:
selectNewQuestionManagementin ngrx state
Navigation¶
- When flag is enabled, nav menu shows "Question Management" (icon:
add_task) routing to['admin', 'questions'] - Legacy "Question Design" route (
['admin', 'question-design']) remains for flag-off state - Located in:
src/services/web/src/app/project/project-nav/project-nav.component.ts
Component Inventory¶
| Component | Location | Completeness | Notes |
|---|---|---|---|
| QuestionManagementComponent | question-management/question-management.component.ts |
100% | Tab container (Design/Assign/Preview), computed signals for child questions |
| Routes | question-management/question-management.routes.ts |
100% | All 3 tabs routed with guards for stage-based routing |
| DesignComponent | question-management/design/design.component.ts |
95% | Tree visualization, Material tree, tab group, loading indicator |
| DesignStore | question-management/design/design.store.ts (28KB) |
100% | Full ngrx signal store: question tree, drag-drop, focus, view state, conditional parent answers, boolean/options conditionals, validation |
| DragDropFeature | design/annotation-question-tree-drag-drop.feature.ts (23KB) |
100% | Complete drag-drop implementation: start/over/leave/drop, dropzone positioning, tree reorganisation, sibling vs child insertion |
| TreeFeature | design/annotation-question-tree.feature.ts |
100% | Flat→tree conversion, category grouping, node maps with ancestor/descendant tracking |
| QuestionsFeature | design/annotation-questions.feature.ts |
100% | Generic signal feature: allQuestionsByCategoryMap, allQuestionMap, named prefixes for multiple question sets |
| QuestionNodeComponent | design/question-node/ |
90% | Individual node: edit/delete/copy actions, nested rendering, validation feedback. Delete/copy are stubs |
| AssignComponent | assign/assign.component.ts |
90% | Stage selector, question counts, Assign/Unassign button |
| AssignStore | assign/assign.store.ts (31KB) |
100% | Dual-tree UI (unassigned↔assigned), drag-drop between trees, batch operations, tree reset, computed counts |
| StageAssignComponent | assign/stage-assign/ |
100% | Questions for specific stage with guard |
| PreviewComponent | preview/preview.component.ts |
30% | Stage selector only — preview rendering not implemented |
| StagePreviewComponent | preview/stage-preview/ |
30% | Skeleton — needs significant work |
| EditComponent | edit/edit.component.ts |
80% | Question property editing UI |
| EditFormValidations | edit/edit.form-validations.ts |
80% | Validation rules for question creation/editing |
| HybridComponent | hybrid/hybrid.component.ts |
Legacy | Example showing hybrid/legacy integration |
Backend (C#) — Complete Models, No New API Endpoints¶
The backend models are fully implemented but there are no new API endpoints for the v2 question management operations. Current CRUD goes through the existing AnnotationQuestionController:
AnnotationQuestion.cs— Full entity with Target, Options, Categories, SubquestionIdsTarget.cs— Conditional parent logic with schema versioning (v0/v1)AnnotationAnswerTallyStore— Tracks annotation counts per question (for locked question checks)
What's Complete vs. What's Missing¶
Complete (can be reused): - Tree-based question visualization with drag-and-drop - ngrx signal stores for design and assign workflows - Question creation/editing UI (form fields, options, conditionals) - Dual-tree assign/unassign interface - Feature flag infrastructure - Navigation integration - Route configuration with guards
Missing (needs implementation):
- Backend API persistence — store methods don't call backend APIs (stubs)
- Question versioning (no QuestionVersion model or UI)
- Version history / diff viewer
- Stage question config management (composing and versioning question configs per stage)
- Preview tab rendering
- Delete/copy actions in design store
- Undo/redo
- Comprehensive test coverage
Unmerged PR Code Worth Salvaging¶
| PR | Key Code | Reuse Value |
|---|---|---|
| #1649 | validation-utils.ts (+550 lines) — comprehensive validation for parent-child question relationships, option consistency, conditional state |
High — core validation logic needed for both design page and backend validation service |
| #1649 | new-option/new-option.component.ts — dedicated component for adding/editing question options |
High — cleaner than current inline option editing |
| #1649 | form-model.directive.ts, form-model-group.directive.ts — enhanced template-driven form directives with dirty tracking |
Medium — patterns useful for form v2 |
| #1564 | edit-text-dialog/, edit-options-dialog/ — wizard components for editing locked questions |
High — needed for the "edit answered question" workflow (Discussion #1646 requirement) |
| #1586 | locked-question.svg + lock icon integration on assign page |
Medium — visual asset and pattern reusable directly |
| #1631 | design.store.ts additions for preview state |
Low — store has been rewritten since; preview approach may need rethinking |
Angular UI Components¶
Strategy: Extend Existing v2, Don't Rebuild¶
The existing question-management/ component tree is the foundation. New features should be added to the existing components, not built as parallel replacements.
Signal forms mandate: All form-driven interactions (question creation/editing, stage question config, edit wizards) must use Angular 21
@angular/forms/signalsinstead ofReactiveFormsModuleor template-driven forms. See Angular 21 Signal Forms section below for detailed patterns.
Design Tab (Existing — Extend)¶
Existing: src/services/web/src/app/project/project-admin/question-management/design/
Extend with:
- Version indicator on each question node showing current version number
- "Save as New Version" button when editing an existing question (currently saves in-place)
- Version diff sidebar when editing — shows what changed vs. previous version
- Backend persistence — connect DesignStore methods to API (currently stubs)
- Delete/Copy — implement stub methods in QuestionManagementComponent
Assign Tab (Existing — Extend)¶
Existing: src/services/web/src/app/project/project-admin/question-management/assign/
Extend with: - Version selector per question when adding to stage (default: latest) - Config preview showing exactly what annotators will see - Config history showing previous stage question configurations
Incorporate from unmerged PRs: - Lock icon for system questions (PR #1586 pattern) - Placeholder text improvements (PR #1649)
Preview Tab (Existing — Complete)¶
Existing: src/services/web/src/app/project/project-admin/question-management/preview/
This tab is only ~30% complete and needs:
- Question rendering matching the annotation form layout
- Conditional logic visualization (show/hide based on parent answers)
- Sample answer display for each control type
- Version-aware rendering from the stage's embedded StageQuestionConfig
Incorporate from unmerged PRs: - Design page preview approach (PR #1631 — adapted for new store structure)
New: Question Version History¶
Add as a new view/panel within the Design tab: - QuestionHistoryComponent: Timeline of versions with expand/collapse - Side-by-side diff between selected versions - Filter by date, user
New: Edit Wizards for Locked Questions¶
From Discussion #1646 and PR #1564 — questions that have already been answered need special handling: - EditTextDialog — wizard for changing question text on answered questions - EditOptionsDialog — wizard for modifying options on answered questions - Entry point: overflow menu on locked question nodes (padlock icon) - Wizard must warn about implications and create a new version
Angular 21 Signal Forms¶
Shared technology decision: Both the Question Management UI and the Annotation Form v2 must use Angular 21 experimental signal-based forms (
@angular/forms/signals). This ensures consistency and avoids maintaining two different form paradigms.
All form-driven interactions in the QM UI must use signal forms instead of ReactiveFormsModule or template-driven forms:
Design Tab — Question Editing Form¶
The question creation/editing form (currently in EditComponent) must use form() with schema validation:
import { form, required, validate, schema } from '@angular/forms/signals';
interface QuestionEditModel {
text: string;
questionType: string;
controlType: string;
category: string;
optional: boolean;
multiple: boolean;
helpText: string;
options: QuestionOptionModel[];
}
@Component({ ... })
export class EditComponent {
readonly model = signal<QuestionEditModel>(defaultQuestion());
readonly questionForm = form(this.model, (fields) => {
schema(fields.text, required(), validate(v =>
v.length > 500 ? { maxLength: true } : null
));
schema(fields.questionType, required());
schema(fields.controlType, required());
schema(fields.category, required());
});
}
Template bindings use [formField]:
<mat-form-field>
<mat-label>Question Text</mat-label>
<input matInput [formField]="questionForm.controls.text" />
@if (questionForm.controls.text.errors()?.required) {
<mat-error>Question text is required</mat-error>
}
</mat-form-field>
<mat-select [formField]="questionForm.controls.controlType">
@for (ct of controlTypes; track ct.value) {
<mat-option [value]="ct.value">{{ ct.label }}</mat-option>
}
</mat-select>
Assign Tab — Stage Question Config Form¶
The version selector for assigning questions to a stage uses signal forms:
interface StageQuestionConfigModel {
selectedQuestions: Array<{ questionId: string; versionNumber: number; position: number }>;
}
readonly configForm = form(this.configModel, (fields) => {
schema(fields.selectedQuestions, required(), validate(qs =>
qs.length === 0 ? { minQuestions: true } : null
));
});
Edit Wizards — Dialog Forms¶
The EditTextDialog and EditOptionsDialog from PR #1564 must be reimplemented with signal forms:
// edit-text-dialog.component.ts
interface EditTextModel {
newText: string;
reason: string;
}
readonly editForm = form(this.model, (fields) => {
schema(fields.newText, required(), validate(v =>
v === this.originalText() ? { unchanged: true } : null
));
schema(fields.reason, required());
});
// Submit creates a new question version
onSubmit() {
submit(this.editForm, async (value) => {
await this.questionApi.createVersion(this.questionId(), {
text: value.newText,
changeReason: value.reason,
});
});
}
Validation Utilities (from PR #1649)¶
The validation-utils.ts (+550 lines) from PR #1649 contains parent-child validation logic. This must be adapted to work with signal forms by converting validators to the validate() / applyWhen() schema pattern:
// Before (PR #1649 — template-driven):
// Custom directive validators + manual form control checks
// After (signal forms):
// Use validate() for synchronous rules
schema(fields.options, validate(options =>
hasDuplicateOptions(options) ? { duplicateOptions: true } : null
));
// Use applyWhen() for conditional parent-child rules
applyWhen(
() => this.model().controlType === 'dropdown',
fields.options,
validate(opts => opts.length < 2 ? { minOptions: true } : null)
);
Backend Implementation Notes¶
Validation¶
The Backend Annotation Validation Service must be extended to:
- Validate question versions are self-consistent (e.g., options exist for dropdown types)
- Validate
StageQuestionConfigcomposition (no duplicate questions, valid ordering, referenced versions exist) - Validate that annotations reference valid question version numbers
Indexes¶
No new collections are introduced — all versioning data is embedded in the existing pmProject aggregate. The existing indexes on pmProject are sufficient. No additional indexes required for question management.
See Data Model & Migration for document size monitoring queries that serve as the primary operational concern for embedded data.
Events (MassTransit)¶
public record QuestionVersionCreated(Guid ProjectId, Guid QuestionId, int VersionNumber);
public record StageQuestionConfigUpdated(Guid ProjectId, Guid StageId, int ConfigVersion);
API Specification (HTTP Examples)¶
Question CRUD¶
# Create a new question (generates version 1)
POST /api/projects/{projectId}/questions
Content-Type: application/json
{
"text": "What was the sample size?",
"questionType": "integer",
"controlType": "textbox",
"category": "Study",
"optional": false,
"description": "Total number of subjects in the study",
"helpText": "Count all subjects, including those lost to follow-up"
}
→ 201 Created { "id": "...", "versionNumber": 1 }
# Create a new version of an existing question
POST /api/projects/{projectId}/questions/{questionId}/versions
Content-Type: application/json
{
"text": "What was the total sample size (all groups)?",
"helpText": "Updated: count all subjects across all experimental groups"
}
→ 201 Created { "versionNumber": 2, "previousVersion": 1 }
# Get all versions of a question
GET /api/projects/{projectId}/questions/{questionId}/versions
→ 200 OK [{ "versionNumber": 1, ... }, { "versionNumber": 2, ... }]
# Get a specific version
GET /api/projects/{projectId}/questions/{questionId}/versions/{versionNumber}
→ 200 OK { "versionNumber": 2, "text": "...", "createdBy": "...", "createdAt": "..." }
Stage Question Configuration¶
# Update stage question configuration (creates a new config version)
PUT /api/projects/{projectId}/stages/{stageId}/question-config
Content-Type: application/json
{
"questions": [
{ "questionId": "...", "versionNumber": 2, "position": 0 },
{ "questionId": "...", "versionNumber": 1, "position": 1 }
]
}
→ 200 OK { "configVersion": 3 }
# Get current stage question configuration
GET /api/projects/{projectId}/stages/{stageId}/question-config
→ 200 OK {
"version": 3,
"questions": [...],
"effectiveFrom": "2026-03-01T00:00:00Z"
}
# Get configuration history for a stage
GET /api/projects/{projectId}/stages/{stageId}/question-config/history
→ 200 OK [
{ "version": 1, "questions": [...], "effectiveFrom": "2026-01-01" },
{ "version": 2, "questions": [...], "effectiveFrom": "2026-02-15" }
]
Acceptance Criteria Summary¶
Versioning & Stage Configuration (New)¶
- Editing a question creates a new version; previous versions remain immutable in
_versionHistory - Stage question configs can be composed with drag-to-reorder
- Updating a stage config appends the previous config to
QuestionConfigHistory - Historical annotations resolve to the correct question version via
QuestionVersionNumber - Full audit log for all question management operations
- RBAC: only Project Admins can manage questions
- API validates all business rules server-side
- System questions handled automatically (no manual versioning)
- Migration preserves all existing annotation data
MVP Feature Parity (from Discussion #1646 — must not regress)¶
- Users cannot unassign system questions related to data extraction if extraction is enabled on a stage
- All user-created questions are usable by the annotation form (no invalid combinations)
- Invalid parent-child states are prevented — if parent options change, child conditional options must be re-validated
- Validation errors shown when: question text too long, question text empty, duplicate options, options don't match data type
- When parent gets a new option, child questions show validation requiring user to select/deselect the new option (asterisk + indeterminate checkbox)
- Questions that have been answered cannot be edited (locked state); edit wizards provide controlled modification
- Automatic check on whether a question has been answered (annotation tally)
- If annotations are deleted, questions become editable again
- Preview displays correct control type based on question settings
- Assign page is intuitive for naive users (placeholder text, clear action labels)
- Drag-and-drop works reliably and performantly
- Dirty check before leaving assign page with unsaved changes
Locked Question Wizards (from Discussion #1516 / PR #1564)¶
- Edit text wizard available for answered questions
- Edit options wizard available for answered questions
- Wizard entry point via overflow menu (padlock icon)
- Wizard creates a new question version (does not mutate existing)
Angular 21 Signal Forms (@angular/forms/signals)¶
- Question creation/editing form uses
form()+[formField]— noReactiveFormsModule - All form validation uses schema-based
required(),validate(),applyWhen() - Edit wizards (text, options) use signal forms with
submit()for typed submission - Assignment form (date picker, version selector) uses signal forms
- PR #1649 validation logic adapted to
validate()/applyWhen()schema pattern - Conditional parent-child rules use
applyWhen()for reactive enable/disable