Skip to content

ADR-079: Measurement-schema closure pass (Wave 2) — process capture, mode-stratified aggregation, staleness annotation

Status: Accepted Date: 2026-05-31 Context: ADR-078 shipped the three no-migration-risk closures from the measurement-schema research (Wave 1) and explicitly deferred the migration-bearing closures to "its own ADR." This is that ADR. It records the four Wave 2 closures: the two capture-gap promotions (attention_state, option_tap_trace), the Q4a mode-stratification of aggregations, and the Convention 7 staleness annotation for state-sensitive measured outcomes. Per the architectural-independence finding these are mutually independent and can ship in any order; they are grouped here because they share the "needs a schema change" property and because Wave 2 builds on the Wave 1 exposure changes (Convention 6 N-propagation, the E10/E14 rename).

At a glance

What it decides: Ships the four migration-bearing measurement-schema closures (Wave 2) deferred by ADR-078attention_state and option_tap_trace capture, Q4a per-mode aggregation, and Convention 7 "as of [date]" staleness annotation.

  • Process capture (Principle 7): attention_state (tab focus/blur) lets E6 separate distracted-slow from deliberated-slow; option_tap_trace gives solo players a persuadability signal (tap A→C→A reveals within-round revision).
  • Q4a mode-stratification: aggregates compute per-mode (solo vs multi) in one additive aggregates_by_mode column; pooled columns stay byte-for-byte unchanged for backward compat.
  • Convention 7: only state-sensitive M4/M5 claims in the agent_context_block prose get dated (reusing summary_generated_at); durable pattern outcomes and the share card are exempt.
  • Migrations 114–116 committed but not applied to remote — applied deliberately at deploy time; may need renumbering if a concurrent branch lands first.
  • Watch: whether option_tap_trace yields enough solo revisions to be worth aggregating into E7 (instrument first, aggregate later).
flowchart LR
    W["Wave 2<br/>(migration-bearing)"] --> C1["attention_state<br/>(closes E6 slow contamination)"]
    W --> C2["option_tap_trace<br/>(closes E7 solo null)"]
    W --> C3["Q4a aggregates_by_mode<br/>(solo vs multi)"]
    W --> C4["Convention 7<br/>'as of [date]' on M4/M5 prose"]

The four independent Wave 2 closures, each requiring a schema or exposed-prose change.

Decision

1. attention_state capture (closes E6 slow-regime contamination)

Track tab focus/blur during the decision window (scenario render → submit) via the Page Visibility API, and persist it on the round alongside C4 decision_time_ms. Stored as a JSONB column attention_state on choices: { hidden_ms, blur_count, was_hidden }. This lets E6 (decision-timing signature) distinguish a distracted-slow decision (tab was hidden / lost focus mid-decision) from a deliberated-slow one — the slow-regime contamination Decomposition 13 flagged. Fast-regime timing is intrinsically robust and needs nothing.

2. option_tap_trace capture (closes E7 solo-mode null)

Record the sequence of option taps before final submit (each tap: { option_id, t_ms } relative to the decision-window start), persisted as a JSONB column option_tap_trace on choices. Primarily solo: it gives E7 (persuadability) a structured within-round signal that parallels multiplayer's post-discussion stand/revise — a solo player who taps A→C→A reveals a within-round revision that the final choice alone hides. Closes the Swing 4 finding that solo players otherwise have a null persuadability signal.

3. Q4a mode-stratification of aggregations

A4 (category_patterns), A9 (calibration_stats), and A7 (signal aggregates) compute per-mode (solo vs multi), not only pooled. Implemented as a single additive JSONB column aggregates_by_mode on player_decision_profiles holding { solo: {...}, multi: {...} }; the existing pooled columns are unchanged, so backward compatibility holds (pooled values still available). Mode is derived from the existing GameOutcome.gameType (multiplayermulti; solo/speed_roundsolo). Closes the Swing 9 finding that regime-shifted players (who behave differently solo vs in a group) are invisible in the pooled view.

4. Convention 7 "as of [date]" annotation

State-sensitive measured outcomes — M4 (calibration drift) and M5 (user trust state with the agent) — carry an explicit "as of [date]" annotation when surfaced through paste-based exposure that won't auto-refresh. Pattern outcomes (M1, M2, M3, M6, M7, M8, M9) are durable across paste-freshness windows and are not annotated. Closes the Swing 10 repeat finding. No migration required — exposure-layer change reusing the existing summary_generated_at timestamp (migration 077).

Where the annotation lands (verified against the codebase): M4/M5 only appear in the agent_context_block prose — the block paste-users drop into ChatGPT/Claude custom instructions. So the annotation is applied at context-block generation (summary.ts): the prompt instructs the LLM to date state-sensitive (calibration-trend / trust-toward-agent) claims, and a deterministic "Profile snapshot as of [date]" line is appended so the date survives regardless of LLM compliance. The share card (share-card-data.ts) surfaces only sync_score + driver mix — durable pattern outcomes, which Convention 7 explicitly exempts — so it correctly needs no annotation; the agent account page is paste-in (the user's AI memory), not paste-out, so it is not a surface for this. Both were checked rather than assumed.

Rationale

  • Why capture the process, not just the outcome (Principle 7). decision_time_ms and the final option are final-state captures; tab-focus and tap-sequence are the co-captured process that disambiguates otherwise-identical final states (slow-because-distracted vs slow-because-deliberating; A-chosen-directly vs A-chosen-after-revising). This is the same Principle 7 logic as Wave 1's A8 note-semantics, applied at capture instead of aggregation.
  • Why a separate aggregates_by_mode column rather than nesting by_mode inside each existing JSONB. Keeping the stratified data in one new column leaves every existing pooled structure byte-for-byte unchanged — the safest possible backward-compat guarantee — and makes the stratification trivially droppable if it doesn't earn its keep.
  • Why Convention 7 reuses summary_generated_at. The thing that goes stale is the generated prose snapshot; its generation timestamp already exists and is exactly the right "as of" date. Adding a new timestamp would duplicate it.

Alternatives Considered

  • Dwell-time / mouse-movement capture instead of Page Visibility. Rejected — ambiguous (reading vs idle) and heavier; ADR-024 already rejected dwell time for the same reason. Tab focus/blur is an unambiguous, cheap binary.
  • option_tap_trace in multiplayer too. Deferred — multiplayer already has the explicit stand/revise capture (C10); the gap this closes is solo-only. Capturing it in multiplayer is redundant for now.
  • Mode as a third axis inside by_category (category × mode nesting). Rejected — explodes the structure and entangles two independent stratifications; a flat top-level aggregates_by_mode is simpler and independently droppable.
  • A new *_last_updated column for Convention 7. Rejected — summary_generated_at already captures snapshot freshness.

Discussion

The four closures were deliberately split from Wave 1 because each changes the schema (three add columns; Convention 7 changes exposed prose), whereas Wave 1 was pure consumer-discipline. Grouping the migration-bearing work lets the migrations land and be reviewed together while staying individually revertible. The capture features (1, 2) and the aggregation/exposure changes (3, 4) don't interact — confirming the architectural-independence finding holds in implementation, not just on paper.

Migration-numbering caveat: these take migrations 114–116 against the Wave 1 base. A concurrent branch in the repo is also adding migrations; if it merges first, these may need renumbering to stay sequential. Per the project workflow, the migration files are committed but not applied to the remote — they are applied deliberately at deploy time.

Consequences

  • choices gains attention_state and option_tap_trace (both nullable JSONB; existing rows null — fully backward compatible).
  • player_decision_profiles gains aggregates_by_mode (nullable JSONB; pooled aggregates unchanged).
  • Solo gameplay capture instruments tab visibility and option taps during the choosing phase (non-blocking; failure to capture degrades to null, never blocks submit).
  • E6 can flag distracted-slow rounds; E7 has a solo persuadability signal; regime-shifted players are discriminable; paste-based M4/M5 surfaces state their snapshot date.
  • Watch for: migration renumbering on merge if the concurrent branch lands migrations first; whether option_tap_trace produces enough solo within-round revisions to be worth aggregating into E7 (instrument first, aggregate when the data justifies it).

Key files: - migrations/114_choices_attention_state.sql, migrations/115_choices_option_tap_trace.sql, migrations/116_profiles_aggregates_by_mode.sql — schema (committed, not applied) - src/app/solo/page.tsx — Page Visibility + tap-trace capture during choosing; persisted on the choices upsert - src/types/database.tsChoice.attention_state, Choice.option_tap_trace, PlayerDecisionProfile.aggregates_by_mode - src/lib/ai/player-model.ts — mode-derived stratified updates for category_patterns / calibration / signal aggregates - src/lib/agent/summary.ts — Convention 7 "as of [date]" annotation on the agent_context_block (prompt instruction for M4/M5 claims + deterministic snapshot-date line). Share card / agent page verified to not surface M4/M5, so unchanged.