Skip to content

ADR-082: "None of the above" flow cleanup — rationale unification, genuine-indifference path, symmetric reveal

Status: Accepted Date: 2026-06-01 Context: The NoTA ("None of the above") path accumulated five documented issues across two play sessions (catalogued in the project-duplicate-rationale-fields-nota-flow memory, surfaced during CoachJ's 2026-05-31 cross-domain experiment play). They span UI redundancy, a hard gate that injects false signal, and a broken reveal display. Two of the five turned out to be already-decided or already-shipped elsewhere; this ADR records the decisions for the rest and, where one contradicts an earlier ADR, amends it. The blocking question — are rationale and none_rationale one primitive or two — had to be settled before any UI work, because the answer determines what the correct UI is.

This ADR depends on and amends ADR-017 (none-of-the-above as a first-class choice) and inherits the decision-time position from ADR-079 (measurement-schema closure Wave 2).

At a glance

What it decides: Cleans up the "None of the above" (NoTA) flow from ADR-017 — it unifies the duplicate rationale fields, adds a genuine-indifference path, and fixes the reveal display so it shows the right label on both solo and multiplayer.

  • rationale and none_rationale are one primitive — unified at the UI (one field at a time), no migration, since the loader already coalesces them
  • Amends ADR-017's mandatory closest pick — a "no lean / genuinely indifferent" toggle makes it optional, stored as none_of_above = true + none_closest_option_id = null with no new column
  • Shared formatChoiceLabel() helper renders NoTA labels identically across surfaces, fixing the blank (solo) and unmarked-closest (multiplayer) bugs
  • Downstream consumers must read null closest under NoTA as intentional indifference, not missing data; watch the no-lean usage rate as a signal on option-set narrowness
flowchart TB
    A["Player picks<br/>'None of the above'"] --> B{"Have a lean?"}
    B -->|Yes| C["Pick closest option<br/>+ required rationale"]
    B -->|"No lean / indifferent"| D["Indifference toggle<br/>closest = null, required rationale"]
    C --> E["Reveal via formatChoiceLabel():<br/>'None of the above (closest: X)'"]
    D --> F["Reveal via formatChoiceLabel():<br/>'None of the above'"]

The cleaned-up NoTA path: a default closest pick or an explicit indifference toggle, both routed through one shared reveal helper.

Decision

1. rationale and none_rationale are one logical primitive — unify at the UI, no migration

They capture the same thing (the player's reasoning for what they'd actually do), contextualized by none_of_above. The consumption layer already proves this: the agent loader (load-profile.ts) coalesces none_rationale ?? rationale, and the measurement schema's E10 ("rationale-choice expression match") does not distinguish them. Therefore:

  • Keep both DB columns (no migration — the loader already treats them as one; a column merge + backfill would be migration risk for zero consumer benefit, against ADR-079's no-migration-risk preference).
  • Never show both fields at once. On the NoTA path the UI shows only the sub-UI's "What would you actually do?" field (writes none_rationale); off the NoTA path it shows only "Teach Your Digital Twin" / "Why this one?" (writes rationale). This removes the duplicate-box confusion at the source.

2. Runner-up block hidden on the NoTA path

The "Was there another option you seriously considered?" runner-up section duplicates the NoTA closest-match picker. Hidden when none_of_above is true. (Multiplayer already did this; solo did not — now symmetric.)

3. Genuine-indifference path — closest-match is no longer mandatory (amends ADR-017)

ADR-017 required a closest pick on every NoTA and explicitly rejected "None without closest pick" to avoid a data void. That blanket requirement is amended: a player who picks NoTA because they are genuinely indifferent across the options (pragmatic-routing with no lean — a signature pattern for some players, CoachJ included) was forced to inject a closest pick that does not exist, polluting the profile with a false directional preference.

  • A "No lean — I'd route this elsewhere / genuinely indifferent" affordance is added to the NoTA sub-UI, mutually exclusive with the closest-match picker.
  • When active, the submit gate no longer requires none_closest_option_id. It persists as null, and none_of_above = true + none_closest_option_id = null is itself the unambiguous indifference signal — no new column.
  • On this path the profile/learning-note update is skipped (it is already guarded on a resolved closest option). This matches ADR-017's own scoring intent: no closest → no 0.3×-weighted directional signal to record. The required free-text rationale still trains the twin at full weight, exactly as ADR-017 wanted.

The closest-match requirement is retained as the default — only the explicit indifference toggle relaxes it. This honors ADR-017's data-void concern (indifference is now captured as signal, not as a skipped field) while fixing the noise injection.

4. NoTA-aware reveal display, symmetric across surfaces (shared helper)

The reveal "Your Choice" rendered wrong on both surfaces, differently: solo showed blank (it read the 'none_of_above' sentinel out of React state, so the option lookup returned undefined); multiplayer showed the closest option's label with no NoTA marker (it read the persisted chosen_option_id, which stores the closest id — misleading, looks like a straight pick). Both now route through one shared helper, formatChoiceLabel() (src/lib/nota/display.ts), guaranteeing an identical end-state:

  • with a closest pick: "None of the above (closest: X)"
  • genuine indifference: "None of the above"

Applied to solo "Your Choice", multiplayer "Your Choice", and the multiplayer per-player "Everyone's Choices" list, so all NoTA labels stay symmetric. This is the same sentinel-string root cause as the crash patched in 86adb0d (that fixed the profile-update path; this fixes the display path).

5. Decision-time "active vs open-tab" — already resolved by ADR-079, no change here

The original framing (pause the timer when the tab is inactive / add a decision_active_time_ms field) is superseded by ADR-079, which shipped the better design: keep decision_time_ms as raw wall-clock and capture tab focus/blur alongside it in attention_state ({ hidden_ms, blur_count, was_hidden }, migration 114). Two separate signals (distracted-slow vs deliberated-slow) discriminate better than one silently-corrected number. This is already implemented in solo. No new field, no timer pause. Porting the attention_state capture to the multiplayer choosing flow (which currently captures neither attention_state nor decision_time_ms) is deferred to an ADR-079 follow-up — it is capture-parity work, out of this NoTA cleanup's lane.

Rationale

  • The data model already behaved as "one primitive." Unifying the UI brings the surface in line with how the loader and signals already consume the data, rather than inventing a new distinction. Lowest-risk fix: no migration, no analytics change.
  • Forced choices from a wrong option set are noise, not signal (ADR-017's own founding principle). ADR-017 applied that principle to the option set but then re-introduced the same problem one level down by forcing a closest pick. The indifference toggle extends ADR-017's principle consistently.
  • Symmetry via a shared helper prevents the two surfaces from drifting again — a single function is the contract, not two hand-maintained ternaries.

Alternatives Considered

  • Full DB consolidation to a single rationale column (Issue 1): Cleanest long-term model, but requires a migration + backfill + loader/analytics changes for no consumer-visible benefit, since the loader already coalesces. Rejected on risk vs. reward.
  • Keep two rationale fields + add explanatory copy (Issue 1): Leaves two boxes on screen and relies on players reading guidance. Rejected — the confusion is structural; remove the duplication rather than annotate it.
  • Keep closest-match mandatory (Issue 3): Honors ADR-017 verbatim, no ADR change. Rejected — the documented false-signal injection persists for exactly the pragmatic-routing pattern Sync most wants to capture cleanly.
  • Make closest-match unconditionally optional (Issue 3): Drops the weak directional signal ADR-017 wanted for the common NoTA case, not just the indifference case. Rejected — the explicit toggle scopes the relaxation to genuine indifference only.
  • Pause the decision timer / add decision_active_time_ms (Issue 5): The original prompt's framing. Rejected in favor of ADR-079's already-shipped raw-time + attention_state process capture, which is strictly more informative.

Discussion

The one genuinely contested decision was relaxing the closest-match gate, because it overrides a deliberated Accepted ADR (ADR-017) whose Discussion section explicitly debated and rejected "None without closest pick." The reconciliation: ADR-017's concern was a data void — a pure non-choice with zero directional signal. The indifference toggle does not create a void; it captures indifference as signal (none_of_above + null closest is a distinct, queryable state) and still requires the free-text rationale. So the amendment is faithful to ADR-017's intent (avoid forced false signal; keep the rationale as the reward) while fixing a case ADR-017 conflated with the common one. The default remains "pick a closest" — only an explicit, opt-in toggle changes it. This was put to the maintainer (CoachJ) before implementation given it overrides a prior decision; approved.

Two of the five reported issues resolved without new code: Issue 1's schema question was answered by reading what the loader and E10 already do; Issue 5 was answered by ADR-079 having already shipped the superior design. Surfacing these as "already decided" rather than re-litigating them is itself the decision-tracking discipline working.

Consequences

  • The NoTA path shows exactly one rationale field and (off-NoTA) one runner-up question — no duplicate boxes.
  • Genuine-indifference NoTA submissions are now possible and are stored as none_of_above = true + none_closest_option_id = null; downstream consumers must treat null closest under NoTA as "intentional indifference," not "missing data." The loader already does (082: renders "(none of the above)" without a closest clause).
  • NoTA reveal labels are correct and identical across solo and multiplayer via the shared helper.
  • ADR-017's "must pick the closest option" rule is amended (not superseded): closest is required by default, optional under explicit indifference.
  • Watch for: NoTA-with-no-lean usage rate. If it dominates NoTA submissions, the scenario generator's option sets may be too narrow (the same feedback loop ADR-017 flagged for NoTA generally). And per "ship and observe": if playtest shows the indifference toggle is rarely used, the added affordance was cheap; if it is heavily used, that is signal about pragmatic-routing frequency worth feeding back to ADR-050 trust-tiering.
  • Deferred / logged: multiplayer attention_state + decision_time_ms capture parity (ADR-079 follow-up).

Key files: - src/lib/nota/display.ts — new shared formatChoiceLabel() helper (symmetric reveal end-state) - src/app/solo/page.tsx — rationale unification, runner-up hidden on NoTA, indifference toggle + relaxed gate, NoTA-aware "Your Choice" - src/app/play/[sessionId]/page.tsx — same NoTA sub-UI + gate changes; NoTA-aware "Your Choice" + "Everyone's Choices" via the helper - src/lib/agent/load-profile.ts — null-closest label tidy ("(none of the above)" with no closest clause) - docs/decisions/017-none-of-the-above.md — amended by §3 (closest-match no longer unconditionally mandatory) - docs/decisions/079-measurement-schema-closure-pass-wave-2.md — source of the decision-time position (§5)