ADR-006: `conversation_type` as a persisted modality field, rolled out selector-first
Status: Accepted — 2026-06-28 (grill-with-docs session). Decision made; implementation scheduled. · Datum: 2026-06-28
- Status: Accepted — 2026-06-28 (grill-with-docs session). Decision made; implementation scheduled.
- Date: 2026-06-28
- Deciders: Frank (product + frontend) + AI (backend)
- Related:
CONTEXT-conversation-types.md(the glossary this enforces),ADR-001-counselling-modalities-as-modules.md(registration-side view of the same axes), the chat-transport work (ADR-004/ADR-005), memoriesoriso-dev-cluster-liquibase-disabled-configmap,oriso-tenantservice-liquibase-disabled,oriso-predev-deploy-and-ci-model.ADR-009(2026-07-01) adds admin-editable/translatable display labels for the four values below — the enum and its per-value code paths are unchanged, not reopened by that decision.
Superseding rollout note — 2026-07-11
- Frontend PR #340 merged the
getModality()selector, but production components still do not use it; the selector call-site sweep remains open in Frontend #409. - UserService does not yet persist
conversation_type; UserService #382 owns the enum, Liquibase migration/backfill, creation-path stamping, and DTO projection. - The Liquibase-off premise below is historical. On current PreDev, all four backend deployments expose
SPRING_LIQUIBASE_ENABLED=true; UserService, TenantService, and AgencyService log clean changelog runs. ADR-006 therefore uses a registered boot-time UserService changeset, not a manual per-environmentALTER. - Preserve the safety invariant behind the old wording: schema migration must complete before Hibernate validates the new entity fields. PreDev must prove the new changeset ran and the pod became ready.
- Frontend #410 is the current epic; #408 is the four-modality real-browser gate. Reachable Email is outside this ADR.
Context
The platform has four conversation modalities — Agency Counselling, Live Chat, Internal Group Chat, Self-Help Group — but no field anywhere stores which one a conversation is. The kind is re-derived from scattered booleans (registrationType, postcode, Anonymous- username prefix, teamSession, repetitive, consultant-mismatch, presence of matrixRoomId) at ~90 frontend call sites and in backend queries. This is the verified root cause of the recurring "every time we touch the live chat, the chat goes fuzzy" failures: modality, lifecycle status, and structural shape are conflated (e.g. getSessionType() returns enquiry | archived | group | session, mixing three axes), so a change in one bleeds into the others.
Verification (5-agent workflow, 2026-06-28) established two facts that shape the decision:
- The entity split is not clean. Agency Counselling and Live Chat are
Sessionrows; Self-Help is aChatrow; Internal Group Chat is dual-stored —createSimplifiedGroupChatwrites both aSession(teamSession=true,registrationType=REGISTERED) and aChatrow, and itsSessionshape differs from Agency Counselling only by theteamSessionflag. SoteamSessionmust be evaluated beforeregistrationType, and the new column must exist on bothsessionandchat. - The naive "just add the column" path is the signature ORISO crash, and worse than a 500. The dev cluster's
oriso-userservice-configConfigMap forcesSPRING_LIQUIBASE_ENABLED=false, overriding the image'sapplication-dev.properties;spring.jpa.hibernate.ddl-auto=validateis active in every environment. A mapped@Columnwith no matching DB column fails atSessionFactoryinitialisation → pod CrashLoopBackOff at boot (the whole UserService won't start), not a per-request error. The same holds for pre-dev/staging/prod (validate + Liquibase off everywhere).
Decision
- Introduce an explicit
conversationTypemodality as the single source of truth, valuesAGENCY_COUNSELLING · LIVE_CHAT · INTERNAL_GROUP · SELF_HELP. Modality (what kind) and lifecycle status (what stage) are two separate fields and must never be merged into one "type" again. - Persist it as a nullable
conversation_typecolumn on bothsessionandchat(backend is the eventual source of truth), but roll it out selector-first so the field can land without a half-migration crash:- Step 1 — frontend read-contract first. A single pure selector
getModality(conversation): Modalitybecomes the only place modality is decided. It prefersdto.conversationTypewhen present and falls back to the existing heuristic when null. The ~90 call sites collapse to this one function immediately — risk-free, no backend dependency, unit-tested with vitest. Fallback order (verified):chatpresent → (repetitive+WEEKLY ?SELF_HELP:INTERNAL_GROUP); elseteamSession→INTERNAL_GROUP; elseregistrationType===ANONYMOUS→LIVE_CHAT; elseAGENCY_COUNSELLING. - Step 2 — the column, Liquibase-before-Hibernate. Add nullable
@Columnmappings plus one registered UserService changeset forsessionandchat, with deterministic legacy backfill and idempotent preconditions. Boot-time Liquibase is now the schema source of truth; verify its execution before accepting Hibernate readiness. Manual ALTER is emergency fallback only, not the normal rollout. - Step 3 — stamp at creation. Default
conversationTypeat the two choke-pointsSessionService.saveSessionandChatService.saveChat, plus explicit stamping increateSimplifiedGroupChat(both rows),AnonymousConversationCreatorService(LIVE_CHAT), and the easy-to-missAskerImportServiceandChatReCreator(carry-forward) paths.
- Step 1 — frontend read-contract first. A single pure selector
- Backfill is trivial and disposable. With no production users, existing rows get a one-time
UPDATEfrom the heuristic, or are wiped and recreated. The heuristic ingetModality()stays only as a null-fallback and is deleted once the column is confirmed populated everywhere.
Considered options
- Direct backend field, deploy immediately (Frank's first instinct). Cleanest end state, no "two truths" window. Rejected as the sequence, kept as the target: deploying a mapped column before the DB has it = CrashLoopBackOff given validate + Liquibase-off. The selector-first sequence reaches the same end state safely.
- Frontend-derived selector only, no column ever. Cheapest, no migration. Rejected: leaves the source of truth as a heuristic forever; backend queries (e.g. the live-chat queue) still can't filter cleanly by modality, and the
postcode='00000'conflation recurs server-side. - Keep deriving per call site. Rejected: this is the status quo that produces the recurring fuzziness.
Consequences
Positive: one source of truth for modality; the ~90 heuristic sites collapse to one tested function on day one; modality and status stop colliding; new modalities (Self-Help, future video) become an additive enum value; the live-chat queue can filter by conversation_type instead of postcode='00000'.
Negative / cost: current live Liquibase activation still drifts from the unfinished Helm defaults (#10/#11), so migration logs and permissions must be checked during rollout; INTERNAL_GROUP must be stamped on two rows; ambiguous historical SELF_HELP rows must remain nullable or be explicitly audited rather than guessed.
Rollout runbook (for the hardening devs)
- Merge
getModality()selector + vitest; confirm the ~90 sites read only the selector. - Add nullable
@Columnmappings + registered changeset/backfill and test an empty/current MariaDB schema locally. - Merge to
pre-dev; deploy the regular image and verify Liquibase executes the new changeset before Hibernate validation and health becomes green. - Verify Agency Counselling, Live Chat, Internal Group, and Self-Help creation end to end on PreDev.
- Confirm no
NULLconversation_typeafter creating one of each modality; then drop the heuristic fallback.
Status & progress (2026-06-30)
- Step 1 built locally (additive, green): a pure
getModality(item): Modalityselector +Modalityenum (AGENCY_COUNSELLING · LIVE_CHAT · INTERNAL_GROUP · SELF_HELP) insrc/components/session/getModality.ts, with the verified fallback order and an explicit-conversationType-wins path, 8 vitest cases (red→green),tsc --noEmitclean. Worktreefeature/adr006-getmodality-selectorofforigin/dev, not pushed. - The ~90-site sweep (routing existing call sites through the selector) is intentionally deferred — it collides with open PRs #126/#275 on the hot files. Only the selector + tests landed; Steps 2–3 (the
conversation_typecolumn, manual ALTER-before-deploy) remain backend work for the post-collision window.
ADR-005: Matrix federation deliberately OFF; real DNS server_name via a clean homeserver rebuild
Status: Accepted — 2026-06-26 (grill-with-docs session). Timing fixed: the clean rebuild runs early July, before any SDK-crypto work and before real carrier onboarding. · Datum: 2026-06-26
ADR-007: Live-chat liveness — the indicator reads backend Availability, never mirrors or infers it
Status: Accepted — 2026-06-28 (grill-with-docs session). Root causes verified; fix scheduled. · Datum: 2026-06-28