Story: Consolidate history dialogs onto HistoryDialogBase

Table of Contents

This page documents a story in Sprint 23. It captures the goal, current status, acceptance criteria, and the tasks that compose it.

Carried from Sprint 19 (4/7 tasks done); the codegen org-model migration gate (Codegen unified model — org-mode migration) cleared 2026-06-11, so Phase C can now proceed.

2026-07-11 re-scope: the currency pilot's server-side shape (currency_field_mapper + extended get_currency_history_response, PR 1097) was reverted 2026-07-09 in commit 43b5c1a16 — not because the architecture was wrong, but because it was hand-written, non-codegen, and blocking a broader currency codegen-sync effort. The shell's unified-diff command (task F455B2BE, PR 1105), which consumed that shape, is now disabled dead code. Before Phase C (template rollout) resumes, a new design task (Redesign server-side history-diff architecture) revisits two open questions: whether the field-to-string mapper can be fully generic via rfl reflection instead of hand-written/codegen'd per entity, and whether ores.diff should carry intra-value diff spans so colour highlighting is computed once server-side (folding in the promoted capture GitHub-style diff view in history changes tab).

Goal

Every entity history dialog derives from HistoryDialogBase and shares one implementation of the common machinery, so that fixes and UX changes land once instead of 67 times, and the per-dialog code shrinks to what is genuinely entity-specific (field comparisons and labels).

Survey (2026-06-05, the day the CI broke on exactly this duplication):

  • 67 *HistoryDialog classes across ores.qt sub-libraries.
  • HistoryDialogBase already exists in ores.qt.api (signals, markAsStale(), code()) but has exactly one adopter (WorkspaceHistoryDialog).
  • 64 dialogs duplicate the version-list/load pipeline (onVersionSelected, loadHistory, QFutureWatcher plumbing).
  • 6 dialogs (Account, SystemSetting, ChangeReason, ChangeReasonCategory, Country, Currency) hand-roll a changes tab with field-level diffs using four different idioms: a free function, per-call lambdas, a CHECK_DIFF_STRING macro, and inline if-blocks. The Account variant named a class-private alias from an anonymous-namespace function, breaking GCC/AppleClang builds (fixed tactically; this story is the proper fix).

Design direction — two layers:

  1. Server-side diffs (the deeper fix). Computing field-level diffs in the UI is the wrong place: when history views are added to the shell, Wt and HTTP frontends the same comparison code would be repeated in each. Instead:
    • A per-entity mapper converts a domain type to an ordered list of (field name, value) strings — one definition per entity, server side, reusable by every frontend and candidate for codegen from the entity models.
    • A generic NATS history message returns the computed diffs between consecutive versions (field, old value, new value), rather than shipping two full versions for the client to compare. Frontends only render.
  2. Qt consolidation onto the base.
    • DiffResult (list of (field, (old, new)) rows) is defined once in HistoryDialogBase.
    • The base owns the changes-tab rendering flow and calls an abstract calculateDiff(current, previous) template method; derived dialogs implement only the per-entity field comparisons. Once the server-side diff message lands, calculateDiff implementations collapse into rendering the server-provided rows, and the template method may disappear entirely.
    • Shared checkString / checkInt / checkBool helpers live with the base so all dialogs format values identically in the interim.
    • Version-list population, async load, stale handling and toolbar wiring migrate into the base (or an intermediate templated CRTP helper if the version types resist a common interface).

Architecture

Full architecture lives in History Diff Architecture (written by the redesign task, superseding this section's original three-layer sketch and the reverted currency pilot it described). Summary:

  • One generic NATS history request (history.v1.get, entity type + entity id), dispatched server side to a codegen'd per-entity handler via a runtime lookup table — reapplying the existing Entity-composed registrars meta-pattern rather than a typed per-entity subject. No typed domain payload crosses the wire for history.
  • A codegen'd, plain (not hand-written, not generic-reflection) per- entity field-to-string mapper, plus a generated entity_type_of() trait as the single source of truth for the dispatch key.
  • ores.diff (unchanged as a dedicated leaf component) extended with intra-value diff spans, computed once, entity-agnostic by construction.
  • One concrete, non-templated HistoryDialog Qt widget and one shell renderer, shared by every entity — no per-entity Qt classes, no HistoryDialogBase subclassing. Open=/=Revert carry only (entity_type, entity_id, version), dispatched client side through a small codegen'd per-entity action registry (same meta-pattern, client side) that opens the entity's own typed detail dialog.
  • Composite/referenced-entity delegation needs no special-casing: opening another entity's history is just constructing the same dialog with a different entity id.

Migration still runs codegen-free phases before the template rollout, per the original gating principle: the org-model migration gate is clear, but the redesigned mapper/dispatch/HistoryDialog above must land before the ~61-dialog rollout proceeds, since it changes what that rollout produces.

Closed at Sprint 23's end, narrowed to what shipped: the entire new server-side diff architecture and the single generic HistoryDialog widget. The remaining Phase C cleanup task (deleting HistoryDialogBase and the last per-entity dialogs, blocked on every other ores.qt component migrating first) split into a Sprint 24 story — see Retire per-entity history dialogs: Phase C rollout.

Status

Field Value
State DONE
Parent sprint Sprint 23
Now Closed: server-side diff architecture, generic HistoryDialog widget, shell renderer, and UI redesign all shipped. Final cleanup (retiring old per-entity dialogs) split into a Sprint 24 story, blocked on other components' migrations.
Waiting on Nothing.
Next See the Sprint 24 follow-on story.
Last touched 2026-07-21

Acceptance

  • [X] Field-level diffs are computed server-side; no frontend holds comparison logic.
  • [X] ores.diff exists with exhaustive engine tests; mappers and handlers are tested at their own layers.
  • [X] The shell can render history as a unified diff.
  • [X] A single generic HistoryDialog widget replaces the HistoryDialogBase/per-entity-subclass design; ores.qt.refdata fully migrated onto it.
  • [X] No codegen change was forced while the org-model migration was in flight; the template rollout was explicitly gated.

Tasks

Task State Start End Description
Design the history diff architecture and testing strategy DONE 2026-06-05 2026-06-05 Design distributed into this story's Architecture and the tasks' Plans.
Grow HistoryDialogBase and migrate the six hand-rolled dialogs DONE 2026-06-05 2026-06-05 Scope grew to all 64 version-history dialogs; event-log dialogs renamed to Audit. PR 1080.
Create the ores.diff component DONE 2026-06-05 2026-06-05 Flat diff model + engine, std+rfl only; 14 tests. PR 1091.
Pilot server-side history diffs end to end on currency DONE 2026-06-05 2026-06-05 Currency mapper + extended response + handler compose; dialog renders server rows; dogfooded. PR 1097.
Retire per-entity history dialog templates for the generic HistoryDialog ABANDONED 2026-07-11 2026-07-11 Split into six finer-grained sibling tasks below before implementation started; no code changes.
Codegen field mappers from entity models ABANDONED   2026-07-13 Superseded by "Codegen per-entity history field mapper" (5C873944), already DONE.
Show history as a unified diff in ores.shell DONE 2026-06-05 2026-06-06 Unified-diff rendering of the changes rows in the shell; no comparison logic client-side.
GitHub-style diff view in history changes tab DONE 2026-07-13 2026-07-14 Render the history changes tab like a GitHub diff: red old / green new with intra-line highlight, handling multiline fields.
Redesign server-side history-diff architecture DONE 2026-07-11 2026-07-11 Superseded the reverted currency pilot: one generic history subject dispatched server-side to codegen'd per-entity handlers, ores.diff extended with intra-value diff spans, single non-templated HistoryDialog for every entity. See History Diff Architecture.
ores.diff: intra-value diff spans DONE 2026-07-11 2026-07-11 Add diff_span (offset/length) to diff_entry; implement span computation in engine::compute (prefix/suffix for single-line, line-LCS + per-line token diff for multiline); exhaustive test coverage per the redesign task's Notes (single-char, whole-word, prefix/suffix-only, no-overlap, multiline LCS, per-line token diff, added/removed field, identical values, empty-vs-non-empty, unicode).
Codegen the entity_type_of() identifier trait DONE 2026-07-11 2026-07-11 Added to the existing domain class header archetype (not a new file); generated for 27 clean ores.refdata entities, 7 skipped due to pre-existing drift.
Generic server-side history dispatch: one subject, per-entity handlers ABANDONED 2026-07-11 2026-07-11 Split into two finer-grained sibling tasks below before implementation started; no code changes.
Codegen per-entity history field mapper DONE 2026-07-11 2026-07-12 New ores.cpp.presentation facet: render_{entity}_fields() per entity, generated for the same 27 clean ores.refdata entities as entity_type_of().
Generic history.v1.get NATS subject and server-side dispatch registry DONE 2026-07-12 2026-07-13 Add the generic history.v1.get NATS subject and request/response types (entity_type, entity_id -> version/fields/changes), a server-side dispatch registry keyed by entity_type_of() reapplying the entity-composed-registrars meta-pattern, and register each entity's history field mapper as a provider. Remove the per-entity get_<entity>_history subjects/handlers as each entity migrates.
Single generic HistoryDialog widget in ores.qt DONE 2026-07-13 2026-07-13 Build the single, non-templated HistoryDialog widget (version list, async load, toolbar, diff_span colour-highlighted changes tab per the GitHub-style capture) taking (entity_type, entity_id); add the codegen'd per-entity Open/Revert action registry; wire callers to construct HistoryDialog instead of a typed dialog subclass; wire the composite-entity 'Details' affordance for child-driven versions to open another HistoryDialog instance.
Generic unified-diff history renderer in ores.shell DONE 2026-07-13 2026-07-13 Replace the disabled currencies history-diff command with one generic, entity-parameterised unified-diff renderer consuming the generic history.v1.get response (context lines from fields, +/- pairs with span-based highlighting from changes); re-enable it for every entity; update the shell recipes.
Refactor history dialog UI per Gemini review DONE 2026-07-14 2026-07-14 Redesign HistoryDialogBase per doc/analysis/gemini_review_history_dialog.org: master-detail split with a live side-by-side diff panel (semantic colour highlighting, inline word diffing), multi-version compare mode, and a compressed timeline with version pill badges, replacing the current flat Old Value/New Value table and dead right-hand space.
Fix entity history versioning across delete+recreate cycles DONE 2026-07-13 2026-07-13 Fixed read_all's non-deterministic ordering (valid_from tiebreak, currency/country + codegen template); deeper generation-scoping fix deferred, see task Notes.

Decisions

  • Closed at Sprint 23's end with acceptance narrowed to what shipped, rather than left open indefinitely. The single remaining task (retire the last per-entity dialogs) genuinely is the bulk of what's left workwise, but it can't proceed until every other ores.qt component migrates first, so it moved to its own Sprint 24 story rather than keeping this one open with no other active work.
  • A dedicated ores.diff component (not a corner of ores.utility): the types cross every domain service and every frontend; a leaf component keeps the dependency arrows clean and matches the many-small-components style. Stands.
  • Responses carry both fields and changes — mildly redundant, but it makes every frontend a dumb renderer, which is the point. Stands; changes now also carries intra-value diff spans.
  • Superseded 2026-07-11: "keep the typed per-entity history subjects" is replaced by one generic history subject (history.v1.get), dispatched server side to a codegen'd per-entity handler via a runtime lookup table — see History Diff Architecture. The per-entity mapper is codegen'd from the outset (plain, generated code — not hand-written, and not a generic rfl-reflection framework), so "codegen'd mappers deferred outright" is also superseded: it is folded into the redesign rather than deferred to a separate Phase D.
  • HistoryDialogBase (base class + per-entity derived dialogs) is replaced by a single, non-templated HistoryDialog widget shared by every entity; Open=/=Revert carry only (entity_type, entity_id, version), dispatched through a small codegen'd per-entity action registry rather than a typed signal payload. No per-entity Qt class survives the rollout.
  • Composite/referenced-entity history needs no special-casing: delegating to another entity's history is constructing the same HistoryDialog with a different entity id.
  • entity_type_of() lives in the existing domain class header (cpp_domain_type_class.hpp.mustache), not a new companion file or archetype — a separate companion header was built and validated first, then retired: no need for more generated files when the function fits in the entity's existing header. Generation is scoped to ores.refdata for now (the component this story touches), and within it, to entities whose regeneration surfaced no pre-existing drift — see the task's Notes for the affected entities (including party), deferred as a known gap.
  • The history field mapper's per-column type dispatch (new render_is_* flags) is derived from each column's raw cpp_type string — the same ground truth the domain class template itself emits verbatim — never from the repository/SQL layer's is_uuid=/=is_nullable_string=/=is_already_optional flags, which encode a nullable-to-optional promotion the domain class template does not apply. A first implementation attempt used the wrong flags and failed to compile against real entities; see the history-field-mapper task's Notes for the full story.
  • The Gemini-review redesign (task 1EAE3E54) lands on currency only; the other 60+ entities still use the pre-redesign HistoryDialog layout until they migrate onto compare mode, tracked by the sibling Migrate ores.qt.refdata onto the generic HistoryDialog story (now scoped to refdata; other ores.qt components are their own backlog captures). Provenance field names (Modified By, Performed By, Change Reason Code, Change Commentary, Recorded At) are centralized as ores::history::domain::provenance_fields in ores.history.api, shared by the field mappers and any presentation layer that renders them separately from an entity's own diffable fields (e.g. the redesigned timeline cards).
  • ores.history's messaging/service layer (dispatch_registry, history_handler, registrar) depends on ores.database=/ =ores.security directly — it is server-only NATS glue no client ever touches, and every service that registers a history_provider already links both to do anything else. An earlier attempt to keep the whole component a dependency-free leaf (modeled on ores.diff's client-visible field_value=/=diff_result) forced an opaque caller_context string and, when that dropped party/roles/ workspace scope, a JSON pack/unpack workaround — both since deleted. history_provider takes a real database::context; history_handler calls make_request_context() itself like every other handler. Only history_protocol.hpp (wire types) and version_builder.hpp stay a true leaf, since those genuinely are client-visible. See the generic-history-subject task's Notes for the full reasoning.

Out of scope

Emacs 29.3 (Org mode 9.6.15)