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-078 — attention_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_tracegives 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_modecolumn; pooled columns stay byte-for-byte unchanged for backward compat. - Convention 7: only state-sensitive M4/M5 claims in the
agent_context_blockprose get dated (reusingsummary_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_traceyields 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 (multiplayer → multi; solo/speed_round → solo). 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_msand 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_modecolumn rather than nestingby_modeinside 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-levelaggregates_by_modeis simpler and independently droppable. - A new
*_last_updatedcolumn for Convention 7. Rejected —summary_generated_atalready 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¶
choicesgainsattention_stateandoption_tap_trace(both nullable JSONB; existing rows null — fully backward compatible).player_decision_profilesgainsaggregates_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_traceproduces 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.ts — Choice.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.