Skip to content

QM v2 Architecture and Implementation Knowledge

Frozen execution context — preserved from the feat/question-management-v2 GSD workspace before that workspace was deleted. Captures the architectural decisions (D001–D012) and implementation knowledge entries (K001–K019) that drove the QM v2 backend and frontend.

When the entries supersede each other (e.g. K004 has an original and a REVISED version), both are kept in chronological order — the REVISED entry is the most-recent status. Same applies to D004 → D005 (D005 supersedes D004).

Tracked under: PR #2461 / Epic #2488

For "what was built when", see qm-v2-implementation-history.md. For requirements list, see qm-v2-requirements-tracker.md (broader SyRF Platform Evolution scope) and the M001-aligned subset in the implementation history.

Architectural Decisions (D001–D012)

Authoritative summary table — Detailed rationale follows.

# Scope Decision Choice Made By Revisable?
D001 Architecture How should v2 annotation question data flow between API and frontend stores? v2 questions normalised through ngrx global store with effects, not loaded directly into signal stores human Yes
D002 Architecture How should undo / redo and draft history work in the question editor? Operations-based undo stack at session level, with server-side GFS draft snapshots for cross-session recovery human Yes
D003 Architecture How should the properties panel form state relate to the persistent entity state? Signal stores bridge from ngrx for persistent data; local edits are temporary until autosave succeeds human Yes
D004 Integration How should v2 annotation questions be loaded on the frontend? (Originally:) Merge v2 AQs into the existing Project DTO at the API layer human Yes
D005 (supersedes D004) Integration REVISED — How should v2 annotation questions be loaded on the frontend? Separate subscription with tiered DTOs (summary for annotators, detail for admins), loaded on navigation, NOT on project load human Yes
D006 Architecture How should annotation question v2 data decompose in the ngrx store? Two-entity-key split mirroring project summary/details: annotationQuestionsV2 (core) + annotationQuestionV2Details (admin extras) collaborative Yes
D007 Architecture How should published question versions be stored in ngrx and how should the annotation form load the correct version? AQVersions as normalised entities (aqVersions entity key), with version-pinned loading via QuestionRef tuples from SQSVersion / PQSVersion chain collaborative Yes
D008 Architecture Which annotation question properties are structural (immutable on aggregate root) vs content (versioned on AQVersion)? Structural (parentQuestionId, groupAsSingle, category, root, answerArray) on aggregate root; content (text, controlType, dataType, options, optional, multiple, helpText) on AQVersion human Yes
D009 Architecture Should v2 annotation question entity state use the legacy normalizr / ngrx pipeline or modern ngrx SignalStore with withEntities? Root-level ngrx SignalStore with withEntities named collections (question / version / details), withCallState, withUndoRedo, rxMethod — NOT the legacy normalizr pipeline collaborative Yes
D010 Architecture How should concurrent editing of the same question by multiple admins be handled? Optimistic concurrency on draft autosave (version check, 409 on conflict) + SignalR push notifications for near-real-time sync + presence awareness human Yes
D011 Planning When should the dataType move from aggregate root to AQVersion happen? Separate focused PR, not mixed into M006 integration milestone collaborative Yes
D012 Planning Should M006 (frontend-backend integration) be executed before M005 (production migration)? Execute M006 before M005. Plan and execute M006/S01 now instead of M005/S01. human No

D001 — Frontend data flow through ngrx global store

Decision: v2 annotation questions normalised through ngrx global store with effects, not loaded directly into signal stores.

Rationale: The existing codebase normalises all API aggregates (annotation questions, stages, projects) into flat ngrx entity state using NSwag-generated HTTP services, ngrx effects with normalizr, and entity reducers. The v2 signal stores should bridge from this global state rather than loading independently, maintaining consistency with the existing data flow and enabling integration with features that already read from the global store.

Note: D009 later modernised this — the v2 store uses @ngrx/signals SignalStore with withEntities rather than the legacy normalizr pipeline. D001's principle (entities owned by a single source of truth, not duplicated across signal stores) holds; D009 picks the implementation.

D002 — Undo / redo + draft snapshots

Decision: Operations-based undo stack at session level, with server-side GFS draft snapshots for cross-session recovery.

Rationale: The undo stack records operations (not state snapshots) so it can replay inverses. It resets on publish boundaries since published versions are immutable. Undo / redo cancels pending autosave debounce to prevent stale intermediate states. Server-side DraftSnapshotService (already built with GFS retention) handles recovery beyond the browser session. Restore from a snapshot triggers autosave and is itself undoable. Separates session-level undo (fast, local) from persistent recovery (server snapshots).

D003 — Form state ownership

Decision: Signal stores bridge from ngrx for persistent data; local edits are temporary until autosave succeeds.

Rationale: Local signal state provides immediate UI responsiveness (no round-trip delay on keystrokes). Debounced autosave converts local edits into API calls. On success, the ngrx global entity state updates and the signal store picks up the change via the bridge. If autosave fails, local edits remain but the global state stays at last-saved. The signal store never diverges from the server for more than one debounce cycle, and the global ngrx state is always the source of truth for what's persisted.

D004 — (SUPERSEDED) Merge v2 AQs into Project DTO

Original decision (later superseded by D005): Merge v2 annotation questions into the existing Project DTO at the API layer, normalise via the same pipeline.

See K004 (original) for details. D005 below is the most-recent decision.

D005 — (SUPERSEDES D004) Separate subscription with tiered DTOs

Decision: REVISED — Separate subscription with tiered DTOs (summary for annotators, detail for admins), loaded on navigation, not on project load.

Rationale: Bundling v2 AQs in the Project DTO couples question changes to full project notifications, wastes bandwidth sending admin-level detail to annotators, and adds cross-collection queries on every project load. Separate loading means: autosave only pushes the changed question, annotators get a lightweight summary DTO, admins get full detail, and the aggregate boundary stays clean. Mirrors the existing project summary / details decomposition pattern. The NSwag-generated QuestionManagementV2HttpService already has the right endpoints. A new lightweight summary endpoint is needed for the annotation form.

D006 — Two-entity-key decomposition (core + details)

Decision: Two-entity-key split mirroring project summary / details: annotationQuestionsV2 (core) + annotationQuestionV2Details (admin extras).

Rationale: Matches the existing project summary / details pattern. The core entity has question text, controlType, options, parent, etc. — enough for the annotation form. The detail entity has hasDraftChanges, versionCount, annotationCount, draftContent, version history — admin-only data. normalizr replaces the nested details object with a string FK during normalisation. Summary loads for annotators populate only the core entity. Detail loads for admins populate both. mergeEntityState handles the merge. Autosave updates only the detail entity.

D007 — AQVersions as normalised entities, version-pinned loading

Decision: AQVersions as normalised entities (aqVersions entity key), with version-pinned loading via QuestionRef(questionId, versionId) tuples from SQSVersion / PQSVersion chain.

Rationale: Annotators must see the exact AQVersion pinned by their session's SQSVersion, not the latest or draft. Different stages may pin different versions of the same question simultaneously. The core entity has currentVersionId and versionIds[] — summary loads populate one version, detail loads populate all. The annotation form resolves Session → SQSVersion → PQSVersion → QuestionRef(questionId, versionId) tuples, then requests those specific versions from the API. Keeps version history browsable for admins while giving annotators exactly the right content.

D008 — Structural vs content properties

Decision: Structural properties (parentQuestionId, groupAsSingle, category, root, answerArray) on aggregate root; content properties (text, controlType, dataType, options, optional, multiple, helpText) on AQVersion.

Rationale: Structural properties alter the annotation tree shape if changed — parentQuestionId moves a node, groupAsSingle changes annotation grouping, category moves between tabs. These must be immutable for the AQ's lifetime. Content properties (text, controlType, dataType, options, optional, multiple) can change between versions without breaking the tree. Annotations pin to a specific AQVersion which carries the content, so two versions can have different content without conflict. dataType was previously on the aggregate root as frozen, but changing it doesn't alter tree structure — it changes the answer format, and each annotation pins its version.

D009 — Modern SignalStore over legacy normalizr

Decision: Root-level ngrx SignalStore with withEntities named collections (question / version / details), withCallState, withUndoRedo, and rxMethod — NOT the legacy normalizr pipeline.

Rationale: The v2 question management is a new subsystem, not a patch on the existing entity infrastructure. The project already has @ngrx/signals 21, @ngrx/signals/entities, and @angular-architects/ngrx-toolkit installed. withEntities provides entityMap / ids / entities signals per named collection. withCallState handles loading / loaded / error per collection. withUndoRedo from the toolkit handles undo / redo with configurable stack (matching D002). rxMethod with debounceTime handles autosave (matching K002). No normalizr schemas, no actions boilerplate, no reducers needed. The existing v1 entities stay in the legacy ngrx store unchanged. Component-level stores (DesignV2Store etc.) inject the root store for entity data and own only view-specific UI state.

D010 — Concurrent editing: optimistic concurrency + presence

Decision: Optimistic concurrency on draft autosave (version check, 409 on conflict), combined with SignalR push notifications for near-real-time sync and presence awareness.

Rationale: Last-write-wins silently loses changes. Optimistic concurrency with a version counter on DraftContent rejects stale saves with 409. SignalR notifications mean each admin sees others' changes within ~200 ms, so the conflict window is very small (smaller than the 1500 ms debounce). On 409, the client re-fetches and notifies. Presence awareness (who's in QM) provides social awareness that reduces accidental conflicts. Change attribution (lastModifiedBy on drafts) provides accountability.

D011 — dataType move is a separate focused PR

Decision: The domain-model change to move dataType from aggregate root to AQVersion happens in a separate focused PR, not mixed into M006 integration.

Rationale: The change affects FromDraft, Publish, DraftContent, migration service, and test fixtures. Mixing it into the integration milestone adds risk and makes the PR harder to review. A separate focused PR keeps each change reviewable and the integration work focused on wiring.

D012 — Execute M006 before M005

Decision: Execute M006 (frontend-backend integration) before M005 (production migration). Plan and execute M006/S01 now instead of M005/S01.

Rationale: M005 (production migration, staged rollout, legacy removal) cannot proceed without the frontend-backend integration that M006 delivers. You can't migrate production projects to a new model and roll out a new UI if the UI doesn't talk to the API yet. The dependency was implicit in the roadmap — M005 says "depends on M001-M004" but also implicitly depends on M006. User explicitly confirmed this ordering — D012 is non-revisable.

Implementation Knowledge (K001–K019)

Append-only register. Where two entries share an ID (K004, K012, K018), both versions are kept; the REVISED entry is the most-recent.

K001 — Frontend data flow must follow the existing ngrx normalisation pattern (2026-04-02)

Context: The v2 signal stores (DesignV2Store, AssignV2Store, PreviewV2Store) were built as isolated SignalStores with setQuestions() methods, but nothing connects them to the backend API or the global ngrx state. This is the gap that caused M001-M004 to be marked complete prematurely.

Rule: v2 annotation questions MUST follow the same data flow pattern as existing entities:

  1. NSwag-generated API client (QuestionManagementV2HttpService in api-client.generated.ts) — already exists, auto-generated from the v2 controller's Swagger spec. This is the HTTP layer. Do NOT create hand-written Angular HTTP services that duplicate it.
  2. ngrx actions — define actions for v2 question operations (load, save draft, publish, etc.) in a new actions file, following the projectDetailActions pattern.
  3. ngrx effects — effects call QuestionManagementV2HttpService, normalise the response using normalizr schemas, and dispatch success / failure actions. Follow the pattern in project-detail.effects.ts.
  4. normalizr schema — define an annotationQuestionV2Schema (like annotationQuestionSchema = new schema.Entity('annotationQuestions')) for the v2 question entity. Register it in the entity name map.
  5. ngrx entity reducer — add a reducer for v2 questions in the global entity state, following the annotationQuestionEntityReducer pattern with mergeEntityState / removeEntitiesWithIds.
  6. Global store → signal store bridge — the component-level SignalStores (DesignV2Store etc.) should read from the global ngrx store via store.selectSignal() and populate themselves, NOT load data independently. Draft changes and UI-only state (selectedQuestionId, expandedNodeIds, filter toggle) stay in the signal store. Persistent data flows through ngrx.

K008 / D009 update: The v2 implementation actually uses @ngrx/signals SignalStore with withEntities rather than the legacy normalizr pipeline described here. K001's principle still applies — single source of truth, no parallel data flows — but the implementation uses modern signal-store primitives.

Files to reference:

  • Entity pattern: src/services/web/src/app/core/state/entities/annotation-question/
  • Effects pattern: src/services/web/src/app/core/services/project/project-detail.effects.ts
  • Generated API client: src/services/web/src/app/core/services/api-client.generated.ts (search for QuestionManagementV2HttpService)
  • normalizr schemas: annotation-question.entity.tsannotationQuestionSchema
  • Entity reducer composition: entity.reducer.ts

K002 — Question form state, edit tracking, undo/redo, and draft snapshots (2026-04-02)

Edit-state model — what properties are editable:

The DraftContentDto (NSwag-generated) defines the mutable content fields on a published AQ's draft:

  • text (question wording), helpText (annotator guidance)
  • controlType (textbox / dropdown / checkbox / radio / checklist / autocomplete)
  • options (QuestionOptionV2[] — value + description + parentFilter)
  • optional (boolean), multiple (boolean)
  • answerOptionFilters (advanced conditional option visibility)

For unpublished DraftQuestions, all fields are mutable on the DraftQuestion entity itself (embedded in Project).

Structural properties frozen after first publish (cannot be edited, only "Replace with new version"): category, dataType, parentQuestionId, groupAsSingle. (See D008 / K007 for full split.)

State ownership:

State type Where it lives Persistence
Current field values (text, options, etc.) ngrx global entity state (normalised byId) Loaded from API, updated on autosave success
Local edits in progress (uncommitted keystrokes) Signal store / component-level signals Transient, lost on navigation — debounce timer converts to autosave call
UI state (selectedQuestionId, expandedNodes, filter) Signal store (DesignV2Store etc.) Session-scoped, not persisted
Undo / redo stack Dedicated UndoRedoService (session-scoped) Lost on browser session end
Draft snapshots (GFS retention) Server-side, via DraftSnapshotService Persistent (Son / Father / Grandfather retention)

Edit → autosave flow:

  1. Admin edits a field in the properties panel
  2. Component updates local signal state immediately (responsive UI)
  3. Debounce timer (e.g. 1500 ms of inactivity) triggers autosave
  4. Autosave dispatches ngrx action: questionV2Actions.saveDraft({ projectId, questionId, draftContent })
  5. Effect calls QuestionManagementV2HttpService.saveDraft() (PUT endpoint)
  6. On success: effect dispatches action that updates the normalised entity in global state (sets hasDraftChanges: true)
  7. Signal store picks up the updated entity from the global store (bridge)

Undo / redo integration:

  • The undo stack records operations (field changes, additions, deletions, reorders, assignment changes), NOT raw state snapshots
  • Each autosaved change pushes an operation onto the undo stack
  • Undo replays the inverse operation and triggers a new autosave with the reverted content
  • The undo stack resets on publish (published versions are immutable — can't undo past a publish boundary)
  • Undo / redo cancels any pending autosave debounce and restarts the timer — prevents stale intermediate states from reaching the server
  • Stack is session-scoped (lost on page unload) — server-side draft snapshots handle longer-term recovery

Draft snapshot integration:

  • DraftSnapshotService (backend, already built) creates periodic snapshots of all draft state with GFS retention
  • Snapshots are triggered: every N minutes of active editing, on navigation away, on session end, before publish
  • Snapshots capture DraftQuestionContent per question (text, options, helpText, controlType, optional, multiple)
  • Deduplication: unchanged questions reference the previous snapshot instead of storing full content
  • The frontend needs a "Draft history" UI in the Design view to browse and restore snapshots
  • Restore replaces the current draft content and triggers a new autosave (the restore itself is undoable via the undo stack because the current state is snapshotted first)

Key constraint: The signal store must NOT own persistent question data. It bridges from ngrx. Local edits are temporary — they become real when the autosave effect succeeds and the global entity state updates. If autosave fails, the signal store should reflect the error state and the pending local changes, but the global entity state remains at the last successfully saved version.

Files to reference:

  • DraftContentDto: api-client.generated.ts (search for DraftContentDto)
  • DraftContent (backend): src/libs/project-management/SyRF.ProjectManagement.Core/Model/QuestionVersioning/AQVersion.cs
  • DraftSnapshot / DraftQuestionContent: src/libs/project-management/SyRF.ProjectManagement.Core/Model/QuestionVersioning/DraftSnapshot.cs
  • DraftSnapshotService: src/libs/project-management/SyRF.ProjectManagement.Core/Services/DraftSnapshotService.cs
  • Properties panel (current): question-management-v2/design/properties-panel/properties-panel.component.ts

K003 — Form state lives in component-level signals, NOT in the store (2026-04-02)

Context: The PropertiesPanelComponent has local signals (editText, editHelpText, editControlType, editOptional, editMultiple) that mirror the selected question's values. These are the typing buffer — they update instantly on keystrokes, while the actual QuestionNode in the store only updates when autosave succeeds.

Current architecture:

  • question is an input() — the parent component (DesignV2Component) passes the selected QuestionNode from the store
  • An effect() syncs the local signals when the input question changes (user selects a different question) — see K014, this should now use linkedSignal
  • On edit, the component sets the local signal AND emits a draftChange output event
  • The parent component receives the event — currently logs to console, should debounce and dispatch autosave

What the DesignV2Store / AssignV2Store actually own:

  • questions: QuestionNode[] — this should come from ngrx via bridge, NOT be independently loaded
  • selectedQuestionId, expandedNodeIds, selectedCategory, filter — pure UI state, stays in signal store
  • The stores do NOT own form edit state — that's in the component

Data flow should be:

  1. ngrx global state → annotationQuestionsV2 entity (byId / allIds) — sourced from project detail load
  2. Signal store selects from ngrx: store.selectSignal(selectAnnotationQuestionsV2ForProject(projectId))
  3. Signal store computed: questionTree derives from the ngrx-sourced questions + local UI state (expandedNodeIds, selectedCategory)
  4. Properties panel: reads selected QuestionNode, mirrors into local edit signals
  5. On edit: draftChange event → parent debounces → dispatches ngrx saveDraft action → effect calls API → success updates ngrx entity → signal store picks up change via bridge

K004 — (ORIGINAL, superseded by K004 REVISED) v2 annotation questions in the Project DTO (2026-04-02)

Original proposal: Merge v2 annotation questions into the existing Project DTO at the application / API layer.

See K004 — REVISED below for the most-recent decision. The original proposal is preserved here only because the rationale informs why the revision was needed.

The original idea was: backend loads project aggregate (DraftQuestions + PQS embedded), loads v2 annotation questions from pmAnnotationQuestion collection, merges them into a single DTO that includes both legacy annotationQuestions and v2 annotationQuestionsV2. Frontend normalises both via existing schema pipeline. No changes to the existing project loading flow. SignalR change notifications trigger project reload.

Why this was reconsidered: Coupling v2 question changes to full project notifications wastes bandwidth, sends admin-level detail to annotators, and adds cross-collection queries on every project load — see K004 REVISED.

K004 — REVISED: v2 annotation questions loaded as a separate subscription, NOT bundled in the Project DTO (2026-04-02)

Supersedes the original K004 which proposed merging into the Project DTO.

Context: The current architecture sends the entire ProjectWithRelatedInvestigatorsDto (including all v1 annotation questions, stages, searches, etc.) on every SignalR project notification. This means any project change re-transmits every annotation question. For v2, this coupling is worse because:

  • v2 AQs live in a separate collection (pmAnnotationQuestion) — fetching them on every project load adds a cross-collection query even when questions haven't changed
  • Question edits (autosave) would trigger a full project notification including all other entities
  • Annotators don't need the admin-level question detail (version history, draft content, impact data)

Decision: Separate subscription with tiered DTOs.

v2 annotation questions are loaded independently from the project aggregate:

  1. Separate load trigger — v2 questions load when the user navigates to a context that needs them (QM admin views or annotation form), NOT on every project load
  2. Separate ngrx entity keyannotationQuestionsV2 with its own reducer, independent of detailLoaded
  3. Separate SignalR subscription (future) — question change notifications push only the changed question entities, not the full project
  4. Tiered DTOs mirroring the existing project decomposition pattern:
DTO tier Consumer Contents When loaded
AnnotationQuestionV2SummaryDto Annotation form (annotators) questionId, text, controlType, options, optional, multiple, parentQuestionId, system Stage review page init
AnnotationQuestionV2DetailDto QM admin views (admins) Everything in Summary + hasDraftChanges, versionCount, annotationCount, draftContent, publishedVersions QM Design / Assign / Preview init

This mirrors how the project entity has ProjectDbSummaryDto (list views) vs ProjectDetailsDto (detail view) — both normalise into the same projects entity key but with different levels of detail, and the entity reducer merges them via mergeEntityState.

For annotation questions v2:

  • Both DTO tiers normalise into the same annotationQuestionsV2 entity key
  • The summary tier fills the core fields; the detail tier adds admin-only fields
  • mergeEntityState handles the merge — if the detail DTO arrives after the summary, it adds fields without losing existing data
  • If only the summary has been loaded (annotator context), the admin-only fields are simply absent

Frontend flow:

Annotator path:

  1. Navigate to stage review → effect dispatches questionV2Actions.loadForStage({ projectId, stageId })
  2. Effect calls a lightweight endpoint returning AnnotationQuestionV2SummaryDto[] for that stage's SQS
  3. Reducer merges into annotationQuestionsV2 entity state (summary-level fields only)
  4. Annotation form selects from this entity state

Admin path:

  1. Navigate to QM Design view → effect dispatches questionV2Actions.loadForProject({ projectId })
  2. Effect calls getProjectQuestionSet() returning AnnotationQuestionV2DetailDto[] (full detail)
  3. Reducer merges into annotationQuestionsV2 entity state (all fields)
  4. Signal store bridges from this entity state

Autosave path:

  1. Admin edits field → debounce → effect calls saveDraft() → on success dispatches saveDraftSuccess
  2. Reducer patches just the changed question in annotationQuestionsV2 (hasDraftChanges: true, updated content fields)
  3. No project notification needed — only the affected question entity updates

Why this is better than DTO merge:

  • No extra query on project load for projects that haven't been migrated
  • Autosave doesn't trigger full project re-notification
  • Annotators get a lightweight payload (no version history, no draft content)
  • Clean aggregate boundary — pmProject and pmAnnotationQuestion stay independent
  • The QuestionManagementV2HttpService (already generated by NSwag) is the right client
  • SignalR notifications can be scoped to question-level changes

Backend implications:

  • Need a lightweight summary endpoint for annotation form consumption (returns only SQS-filtered questions with summary fields)
  • The existing getProjectQuestionSet() endpoint serves the admin detail tier
  • No changes to ProjectController.GetProject() — v1 AQs continue to flow with the project as they do now

K005 — Annotation question v2 entity decomposition mirrors project summary/details pattern (2026-04-02)

Context: The project entity decomposes into two ngrx entity slices: projects (core / summary — IProject with projectDetails: string FK) and projectDetails (extended — IProjectDetails with child entity ID arrays).

Decision: Annotation questions v2 follow the same pattern.

Two entity slices:

Entity key Interface Contents Source DTO
annotationQuestionsV2 IAnnotationQuestionV2 id, text, controlType, options, parentQuestionId, system, category, optional, multiple, details: string (FK) Both summary and detail tiers
annotationQuestionV2Details IAnnotationQuestionV2Details id, hasDraftChanges, versionCount, annotationCount, draftContent, publishedVersions, draftPublishDecision Detail tier only

DTO shape:

// Summary tier (annotation form) — details is absent or just an ID
interface AnnotationQuestionV2SummaryDto {
  id: string;
  text: string;
  controlType: string;
  options: QuestionOptionV2[];
  parentQuestionId: string | null;
  system: boolean;
  category: string;
  optional: boolean;
  multiple: boolean;
  // no details property — annotators don't need it
}

// Detail tier (QM admin) — details is a nested object
interface AnnotationQuestionV2DetailDto extends AnnotationQuestionV2SummaryDto {
  details: AnnotationQuestionV2DetailsDto;
}

interface AnnotationQuestionV2DetailsDto {
  id: string;
  hasDraftChanges: boolean;
  versionCount: number;
  annotationCount: number;
  studyCount: number;
  draftContent: DraftContentDto | null;
  publishedVersions: VersionSummaryDto[];
  draftPublishDecision: DraftPublishDecision | null;
}

normalizr schemas:

const annotationQuestionV2DetailsSchema = new schema.Entity('annotationQuestionV2Details');
const annotationQuestionV2Schema = new schema.Entity('annotationQuestionsV2', {
  details: annotationQuestionV2DetailsSchema,
});

When the summary tier loads (annotator path), annotationQuestionsV2 gets populated with core fields. The details FK is absent — the entity simply doesn't have it. When the detail tier loads (admin path), both entity slices populate. mergeEntityState handles the merge correctly — if summary loaded first, detail adds the FK; if detail loaded first, summary doesn't overwrite.

K006 — AQVersions as normalised entities with version-pinned loading (2026-04-02)

Context: Annotation sessions are pinned to a specific SQSVersion, which references a PQSVersion containing QuestionRef(questionId, versionId) tuples. Annotators must see the exact AQVersion that was active when their session was created — not the latest version or the draft.

Decision: AQVersions are normalised into their own ngrx entity slice. The core annotation question entity references version IDs (not nested version objects). Loading is version-aware.

Entity decomposition (three levels):

Entity key Interface Contents
annotationQuestionsV2 IAnnotationQuestionV2 id, text, controlType, options, parentQuestionId, system, category, optional, multiple, currentVersionId: string, versionIds: string[], details: string (FK)
annotationQuestionV2Details IAnnotationQuestionV2Details id, hasDraftChanges, draftContent, draftPublishDecision, annotationCount, studyCount
aqVersions IAQVersion id, questionId, versionNumber, text, options, helpText, controlType, optional, multiple, createdBy, createdAt, changeReason, breakingChange

The core entity always has currentVersionId (latest published) and versionIds[] (all version IDs). When the summary tier loads, currentVersionId points to the single version that was returned. When the detail tier loads, all version IDs are populated.

Version-pinned loading (annotator path):

1. Annotator opens stage review → loads session
2. Session has sqsVersionId
3. Frontend resolves SQSVersion → PQSVersion → QuestionRef[] tuples
4. API request: GET /api/v2/projects/{projectId}/questions/by-version
   Body: [{questionId, versionId}, ...]
5. Backend returns AnnotationQuestionV2SummaryDto[] with the SPECIFIC version content
   (text, options, controlType from the requested AQVersion, NOT from draft or latest)
6. Frontend normalises: core entity populated, aqVersions populated with one version per question
7. Annotation form renders using the version-pinned content

Admin path (full versions):

1. Admin opens QM Design view → loads project questions
2. API returns AnnotationQuestionV2DetailDto[] with ALL versions per question
3. Frontend normalises: core entity, detail entity, AND all aqVersions for each question
4. Version history panel reads from aqVersions entity by ID
5. currentVersionId on the core entity points to the latest published version

Why AQVersions as separate entities:

  • Version history is browsable (admin) — needs individual version access
  • Annotation form pins to a specific version — needs version-level lookup
  • Different consumers need different versions of the same question simultaneously (two stages may pin to different AQVersions)
  • Avoids nesting version arrays inside the question entity (which breaks normalisation)

normalizr schemas:

const aqVersionSchema = new schema.Entity('aqVersions');
const annotationQuestionV2DetailsSchema = new schema.Entity('annotationQuestionV2Details');
const annotationQuestionV2Schema = new schema.Entity('annotationQuestionsV2', {
  details: annotationQuestionV2DetailsSchema,
  versions: [aqVersionSchema],  // only present in detail tier
});

Session resolution chain:

Session → sqsVersionId → SQSVersion.pqsVersionId → PQSVersion.orderedQuestionRefs[]
→ [{questionId, versionId}, ...] → API request with version tuples → version-pinned DTOs

Files to reference:

  • QuestionRef: VersioningValueObjects.csrecord QuestionRef(Guid QuestionId, Guid VersionId)
  • PQSVersion: ProjectQuestionSet.csOrderedQuestionRefs: List<QuestionRef>
  • SQSVersion: StageQuestionSet.csQuestionIds + PqsVersionId
  • AQVersion: AQVersion.cs — immutable content snapshot

K007 — Structural vs content properties: what belongs on the aggregate root vs the version (2026-04-02)

Context: The AnnotationQuestionV2 aggregate root currently has some properties that should be versionable (content) alongside properties that are genuinely structural (immutable). The frontend entity decomposition must reflect this distinction accurately.

Decision (user-directed, see D008): Properties split into two categories based on whether changing them alters the annotation tree structure.

Structural (on aggregate root — immutable for the AQ's lifetime):

  • parentQuestionId — changing this moves the question in the tree, breaking conditional chains
  • groupAsSingle — changing this alters how annotations are grouped (single unit vs separate)
  • category — changing this moves the question between category tabs
  • root — derived from parentQuestionId being null
  • answerArray — changes how answers are stored structurally
  • labelQuestion — system structural role
  • annotationLookup — system structural role
  • system — system flag

Content (on AQVersion — changes between versions, pinned per session):

  • text — question wording
  • helpText — annotator guidance
  • controlType — textbox / dropdown / checkbox / radio / checklist / autocomplete
  • dataType — string / integer / decimal / boolean
  • options[]QuestionOptionV2 values
  • optional — whether answer is required
  • multiple — allow multiple selections

Why dataType is content, not structural: Changing from string to integer doesn't alter the tree. It changes the answer format, but annotations reference a specific AQVersion which pins both the dataType and the expected input. Two versions of the same question can have different dataTypes without conflict because each annotation is pinned to its version.

Impact on frontend entities:

annotationQuestionsV2 (core — structural + FK)
├── id
├── parentQuestionId          ← structural, immutable
├── category                  ← structural, immutable
├── system                    ← structural, immutable
├── root                      ← structural, immutable
├── groupAsSingle             ← structural, immutable
├── answerArray               ← structural, immutable
├── currentVersionId: string  ← FK to latest aqVersion
└── details: string           ← FK to detail entity

aqVersions (content — pinned per session)
├── id                        ← globally unique GUID
├── questionId                ← back-reference to parent AQ
├── versionNumber
├── text                      ← content, versioned
├── helpText                  ← content, versioned
├── controlType               ← content, versioned
├── dataType                  ← content, versioned
├── options[]                 ← content, versioned
├── optional                  ← content, versioned
├── multiple                  ← content, versioned
├── createdBy
├── createdAt
├── changeReason
└── breakingChange

annotationQuestionV2Details (admin extras)
├── id
├── hasDraftChanges
├── draftContent
├── draftPublishDecision
├── annotationCount
├── studyCount
└── versionIds[]              ← array of all version IDs (moved here from core)

Backend domain-model change needed (see D011): Move dataType from AQ aggregate root to AQVersion content. The FromDraft factory and Publish method need to carry dataType through to the version. Per D011, this is a separate focused PR.

K008 — Use ngrx SignalStore with withEntities for v2 question state, not the legacy normalizr pipeline (2026-04-02)

Context: The existing codebase uses a legacy pattern (normalizr → ngrx global store → entity reducers → selectors) for all entities. The v2 question management is a new subsystem that doesn't need to slot into this pipeline. The project already has @ngrx/signals 21, @ngrx/signals/entities, and @angular-architects/ngrx-toolkit installed but unused.

Decision (see D009): The v2 annotation question entity state uses a root-level SignalStore with withEntities named collections, NOT the legacy normalizr pipeline.

Why this is better for v2:

  • withEntities provides entityMap, ids, entities signals per collection — no hand-rolled byId / allIds
  • Named collections ('question', 'version', 'details') map directly to the three entity tiers
  • withCallState({ collections: [...] }) gives loading / loaded / error per collection — no boilerplate
  • withUndoRedo({ collections: [...] }) from ngrx-toolkit handles undo / redo with configurable stack — matches D002
  • rxMethod with debounceTime handles autosave — matches K002
  • withDataService can wire CRUD ops to the NSwag-generated QuestionManagementV2HttpService
  • No normalizr schemas, no actions boilerplate, no reducers, no entity-name-map entries
  • The store is providedIn: 'root' — shared across Design / Assign / Preview views and annotation form

What stays in the legacy ngrx store:

  • v1 annotation questions — annotationQuestions entity key, unchanged
  • Project, stages, memberships, all other existing entities — unchanged
  • The feature flag determines which system the UI reads from

Store structure:

export const AnnotationQuestionV2Store = signalStore(
  { providedIn: 'root' },
  withEntities({ entity: type<IAnnotationQuestionV2>(), collection: 'question' }),
  withEntities({ entity: type<IAQVersion>(), collection: 'version' }),
  withEntities({ entity: type<IAnnotationQuestionV2Details>(), collection: 'details' }),
  withCallState({ collections: ['question', 'version', 'details'] }),
  withUndoRedo({ collections: ['question', 'version', 'details'], maxStackSize: 100 }),
  withComputed(/* derived state: questionTree, filteredQuestions, etc. */),
  withMethods(/* loadForStage, loadForProject, saveDraft, publish, etc. */),
);

How the component-level stores (DesignV2Store, AssignV2Store, PreviewV2Store) relate (see K012 REVISED):

  • They inject the root AnnotationQuestionV2Store and read from its entity signals
  • They own ONLY view-specific UI state (selectedQuestionId, expandedNodeIds, filter, previewMode)
  • They DO NOT own question data — that's in the root store
  • They compute derived state (questionTree, assignmentDelta) from the root store's entity signals + their own UI state

How existing project / stage data is accessed:

  • Stages list: inject legacy Store and use store.selectSignal(selectStagesForProject(projectId))
  • The v2 signal store and legacy ngrx store coexist — components can inject both

K009 — Use withDevtools for SignalStore debugging (2026-04-02)

Context: The project already uses Redux DevTools via @ngrx/store-devtools for the legacy global store. The @angular-architects/ngrx-toolkit provides withDevtools() which registers SignalStores in the same Redux DevTools panel.

Rule: The root AnnotationQuestionV2Store must include withDevtools('annotationQuestionsV2'). This makes all three entity collections (question, version, details), call state, and undo / redo state visible in Redux DevTools alongside the existing ngrx store state. Every patchState call appears as a named action with before / after diff.

K010 — Collaborative editing: presence awareness, change attribution, and optimistic concurrency (2026-04-02)

Context: Multiple admins may access Question Management simultaneously. Changes should be attributed, visible to other admins in near-real-time, and concurrent edits to the same question's draft should not silently overwrite each other.

Three capabilities needed:

1. Presence awareness (who's in QM):

  • When an admin opens QM, broadcast via SignalR: "user X is viewing QM for project Y"
  • Other admins in the same project's QM see avatars / names in the toolbar
  • On navigate away or disconnect, remove presence
  • Lightweight — no per-keystroke broadcast, just enter / leave events
  • Similar to Google Docs "N viewers" indicator

2. Change attribution (who changed what):

  • Every autosaved draft change records lastModifiedBy: Guid (InvestigatorId) alongside lastModified: DateTime
  • The version history (on AQVersion) already has createdBy — this extends to drafts
  • SignalR pushes for question changes include the user who made the change
  • The UI shows "Edited by [name] just now" or similar on recently-changed questions
  • The properties panel shows who last edited each field (if needed — may be overkill initially)

3. Optimistic concurrency on draft autosave:

  • Add version: int to DraftContent (or use the AQ aggregate's audit version)
  • Autosave request includes the version the edit was based on
  • Server checks: if current version != submitted version → reject with 409 Conflict
  • On 409, the client:
  • Re-fetches the current draft state (which includes the other admin's changes)
  • Shows a brief notification: "Another admin updated this question. Your changes have been refreshed."
  • The admin can re-apply their edit on top of the new state
  • SignalR notifications mean this conflict window is very small (sub-second in practice)
  • The debounce timer (1500 ms) is longer than the SignalR round-trip (~200 ms), so in most cases:
  • Admin A saves → SignalR pushes to B within 200 ms → B's local state updates → B's next edit is based on A's version → no conflict

What this means for the store:

  • saveDraft rxMethod must handle 409 responses: re-fetch entity, update local state, surface notification
  • The root store needs a draftConflict signal or similar to surface the conflict to the UI
  • Presence state can be a separate lightweight signal (list of {userId, userName} currently in QM)

Backend changes needed:

  • DraftContent.Version: int field (or leverage existing audit version)
  • SaveDraft endpoint checks version before writing, returns 409 on mismatch
  • SignalR hub method for QM presence (enter / leave broadcast per project)
  • Draft change notifications include modifiedBy user identity

K011 — dataType move from aggregate root to AQVersion is a separate focused PR (2026-04-02)

Context: K007 / D008 identified that dataType should be on AQVersion (content, versioned) not on the aggregate root (structural, immutable). This affects the domain model, FromDraft factory, Publish method, DraftContent model, migration service, and test fixtures.

Decision (see D011): This is a separate focused PR, not mixed into the M006 integration work. It should be done soon but is not a blocker for the integration milestone.

What the PR needs to do:

  1. Move DataType property from AnnotationQuestionV2 to AQVersion
  2. Add DataType to DraftContent model
  3. Update FromDraft factory to carry dataType to the version, not the root
  4. Update Publish method to include dataType from draft / latest version
  5. Update DraftContentDto (API DTO) to include dataType
  6. Update NSwag-generated client (regenerate from Swagger)
  7. Update all test fixtures that create AQs or AQVersions
  8. Update frontend QuestionNode interface if needed
  9. Verify migration service handles the move correctly (existing data may have dataType on root)

K012 — (ORIGINAL, superseded by K012 REVISED) Clean-sheet store design (2026-04-02)

Original proposal: One root AnnotationQuestionV2Store (providedIn: root) with three named entity collections. View-specific UI state (selectedQuestionId, expandedNodeIds, selectedCategory, filter, previewMode) lives as signals directly on the component — not in a separate store. Shared derived state (questionsByCategory, tree builder) lives as withComputed on the root store.

Pragmatic path suggested by the original entry: refactor existing component-level stores to thin wrappers that delegate entity reads to the root store and keep only UI state. This preserves existing tests while aligning with the target architecture. Over time, the wrapper stores can be eliminated and their UI state moved to component-local signals.

See K012 — REVISED below for the most-recent decision: component stores stay as the view-logic layer.

K012 — REVISED: Component stores earn their place as view-logic layer (2026-04-02)

Supersedes the original K012 which recommended eliminating component stores.

Three-layer architecture:

Layer Responsibility Examples
Component Template interactions, event emission Click handlers, form input bindings
Component Store View-specific derived state + orchestration Tree building, assignment delta, autosave debounce, draft assembly
Root Store Entity state, API calls, call state, undo / redo rxMethod for saveDraft / loadForProject / publish, withEntities, withCallState

Why component stores stay:

  • Tree building (flat entities → recursive hierarchy filtered by category + expanded nodes) is real logic with real tests
  • Autosave orchestration (debounce timer, draft content assembly from local edit signals, dispatch to root store) is coordination logic that doesn't belong in the component
  • The component store injects the root store — the component itself doesn't need to know about the root store at all
  • Testable seam: test tree building and autosave logic without rendering the component

Where API calls live:

  • ALL async API interactions on the root store via rxMethod — centralised, equivalent to a classic ngrx effects file
  • saveDraft, loadForProject, loadForStage, publishStage, discardDraft, applyVersionAsDraft — all root store methods
  • Component stores call root store methods but never make HTTP calls directly
  • The root store's withCallState tracks loading / error — API methods update these signals
  • 409 conflict handling lives on the root store (re-fetch entity, surface conflict signal)

Flow example — autosave:

  1. PropertiesPanelComponent emits draftChange event
  2. DesignV2Store receives event → assembles DraftContentDto from local edit state → calls rootStore.saveDraft(projectId, questionId, content)
  3. Root store's saveDraft rxMethod: debounceTime(1500ms)switchMap → API PUT → tapResponse → update entity on success / handle 409 on conflict
  4. Entity update triggers computed signals → component store's derived state updates → component re-renders

K013 — Presence awareness backed by MongoDB change streams (2026-04-02)

Context: Presence awareness (who's in QM, who's looking at which question) needs a persistence layer so it survives API restarts and supports multiple API instances. MongoDB change streams are already the backplane for all other SignalR notifications.

Presence model:

pmQuestionManagementPresence (or embedded on pmProject)
{
  projectId: CSUUID,
  activeUsers: [
    {
      investigatorId: CSUUID,
      name: string,
      avatarUrl: string,
      currentQuestionId: CSUUID | null,
      enteredAt: ISODate,
      lastHeartbeat: ISODate
    }
  ]
}

How it works:

  1. Admin opens QM → API writes presence entry to MongoDB
  2. Change stream fires → API's SignalR hub pushes to all connected clients for that project
  3. Frontend shows avatars in toolbar (who's in QM) and on question nodes (who's viewing this question)
  4. Admin selects a question → API updates currentQuestionId → change stream → SignalR push
  5. Admin navigates away or closes browser → heartbeat expires → API prunes stale entry → change stream → SignalR push

Per-question awareness: currentQuestionId on the presence entry means the question tree shows which questions have other admins' attention. When I see another admin's avatar on a question node, I know they might be editing it — providing social context that reduces accidental conflicts before the optimistic concurrency layer even needs to intervene.

Heartbeat: Frontend sends periodic heartbeats (every 30 s). API prunes entries with lastHeartbeat older than 60 s. This handles the "closed browser without navigating away" case.

K014 — Use linkedSignal for properties panel edit state, not effect() (2026-04-03)

Context: The PropertiesPanelComponent currently uses effect() to sync local edit signals from the question input. The Angular team warns against effect() for state synchronization — it's a side-effect escape hatch, not a state derivation tool. Angular 21 has linkedSignal which is purpose-built for this.

Rule: Use linkedSignal for edit state that resets when the source changes:

// ❌ effect anti-pattern
editText = signal('');
constructor() {
  effect(() => { if (this.question()) this.editText.set(this.question()!.text); });
}

// ✅ linkedSignal — resets when question changes, writable for local edits
editText = linkedSignal(() => this.question()?.text ?? '');

Communication from properties panel to parent: output() for draftChange events. The parent coordinates autosave. No model() needed because the parent doesn't write back to edit signals.

K015 — withCallState is from ngrx-toolkit, resource signals for reads, rxMethod for writes (2026-04-03)

Context: withCallState is from @angular-architects/ngrx-toolkit (already installed). It adds loading / loaded / error signals per named collection. It replaces hand-rolled loading / error state.

Read path — resource signals or withEntityResources:

  • Angular's resource() API loads data declaratively when a request signal changes
  • withEntityResources from ngrx-toolkit combines resource with withEntities
  • When projectId changes → questions reload automatically, no manual dispatch
  • Loading / error state tracked by the resource itself

Write path — rxMethod:

  • saveDraft, publish, discardDraft are imperative user actions
  • rxMethod with debounceTime, switchMap, tapResponse handles these
  • Not declarative reactions — triggered by events, not signal changes

Hybrid approach on the root store:

  • Reads: resource or withEntityResources for loading on navigation
  • Writes: rxMethod for autosave, publish, discard
  • Both update the same entity collections via patchState + entity updaters

K016 — model() vs output() vs linkedSignal for properties panel communication (2026-04-03)

Context: The properties panel needs to communicate edit state to its parent. Three options: output() events, model() two-way binding, linkedSignal for local reset-on-change.

Recommendation: Use linkedSignal for local edit state (resets when question changes), output() for draftChange events. Consider model<DraftContentDto>() as the entire draft state exposed to the parent — the parent debounces and autosaves from the model signal. Decision deferred to implementation.

K017 — withMutations (httpMutation/rxMutation) for write operations (2026-04-03)

Context: The ngrx-toolkit provides withMutations with httpMutation and rxMutation as the write-side equivalent of resource for reads. Each mutation tracks its own isPending(), error(), status signals.

Decision: Use withMutations for write operations (saveDraft, publish, discard). This replaces rxMethod for writes and provides per-operation state tracking without separate withCallState wiring.

Split:

  • Reads: resource / withEntityResources — declarative, reloads on request signal change
  • Writes: withMutations with httpMutation / rxMutation — imperative, per-operation state
  • withCallState may still be useful for overall collection loading state

K018 — (ORIGINAL, superseded by K018 REVISED) Project-scoped store (2026-04-03)

Original recommendation: Project-scoped. Provided on the project route component that wraps Design / Assign / Preview. Fresh store per project, no stale data from previous project, memory freed on project exit.

See K018 — REVISED for the most-recent decision: layered scope.

K018 — REVISED: Entity store at project scope, management logic at feature scope (2026-04-03)

Supersedes original K018 which just discussed root vs project scope.

Layered scope architecture:

Scope What lives here Provided by
Project route AnnotationQuestionV2Store (entities: question, version, details) Project route component
QM admin route Component stores for Design / Assign / Preview (tree building, autosave, assignment delta) QM route or view components
Annotation form Annotation-form-specific stores (version-pinned rendering, answer tracking) Stage review / annotation form component

The entity store is at the project level because both admins and annotators within the same project need the same entities. Management-specific orchestration lives at the QM admin level. Annotation-form-specific logic lives at the stage review level.

Why this works:

  • One store instance per project, shared by all consumers
  • Clean lifecycle — created on project entry, destroyed on exit
  • No entity duplication between admin and annotator paths
  • Sets the pattern for eventually migrating other project entities to project-scoped SignalStores
  • Aggregate-boundary-aligned: each MongoDB aggregate gets its own SignalStore

Merge handling for tiered loading:

  • Annotator loads summary-tier data → core entity fields populated
  • Admin loads detail-tier data → core fields updated, detail + version entities added
  • Entity updaters (updateEntity, setEntity) merge without clobbering
  • If summary loaded first, detail adds to it. If detail loaded first, summary doesn't overwrite.

K019 — Feature flag is global + per-project opt-in (2026-04-03)

Context: The v2 question management replaces the legacy (v1) implementation entirely. The legacy code is in production. The v2 code is behind a feature flag and not yet deployed.

Feature flag logic:

  • Global flag newQuestionManagement: master kill switch. If false, all projects use legacy.
  • Per-project setting: project-level opt-in. Only projects that have opted in (and been migrated) use v2.
  • Combined check: globalFlag && project.useV2QuestionManagement

Route behaviour:

  • One route for question management — the route resolver checks the combined flag
  • If v2: load QM v2 components (Design / Assign / Preview)
  • If legacy: load legacy components (question design page, stage settings assignment)
  • No parallel routes — clean switch based on flag state

Legacy components being replaced:

  • Question Design page → replaced by QM v2 Design view
  • Stage Settings assignment section → replaced by QM v2 Assign view
  • Neither currently has a Preview equivalent — that's new in v2

Cross-References