Skip to main content

ADR: Token-fading schema SHAPE (values stay clinical-tunable)

Date: 2026-07-15 · Status: Accepted (shape); values pending Phase-2 clinical sign-off · Resolves: docs/rebuild-spec.md open item 3 ("Token-fading decay parameters must be defined before the TokenBatch schema freezes") · Grounded in: docs/research/2026-07-14-engagement-expansion-research.md Brief 8 (ABA schedule-thinning literature + architecture audit) and the live schema (20260612000002_tier0_domain.sql, ..000005_household_economy_config.sql).

Context

Fading is the moat: no consumer product implements automated reinforcement thinning. The token_batches table is LIVE with placeholder fade_state ('none','fading','faded') + fade_started_at, but nothing writes it; balances fold ledger_entries only. The clinical framework (Phase 2) will supply parameter VALUES; the rebuild-spec requires the parameter SHAPE before the batch schema freezes. This ADR freezes the shape now so the economy can keep hardening without blocking on the OT review.

Decision 1 — name the two mechanisms; they are independent

  • (a) Reinforcement schedule thinning (the moat's core): the child EARNS less often per chore as the habit stabilizes (FR1 → FR2 → VR3 → social-only → graduated). Changes only FUTURE earn frequency. Per-(member, chore) state.
  • (b) Batch value decay (what TokenBatch.fadeState was scaffolded for): unspent earned tokens dim over time to encourage spending velocity. Default OFF. Flagged risk: decay of savings can READ as punitive even when time-based — enabling it is a product+clinical call, not engineering.

Decision 2 — balance effects go through the ledger, only

A new LedgerEntryKind.fade: Dart enum widening + SQL kind CHECK widening (exact precedent: move was added post-Tier-0). Decay debits are appended via the existing LedgerService.debit path — append-only preserved, zero-floor free, enforce_zero_floor untouched. token_batches never carries balance truth; it only bookkeeps lots (remaining, fade_state, fade_started_at mutate — already designed mutable). No UPDATE-balances shortcut, ever (invariant 2 machinery).

Decision 3 — policy parameter shape

Household defaults: households.fade_policy jsonb not null default '{}' (empty object = all fading disabled). Jsonb (the feature_flags precedent) because values are clinical-tunable and versioned; the SDK service is the validation twin (house rule: invariants in service AND schema — a minimal SQL CHECK asserts it is an object). Documented schema, research defaults shown (ALL values tunable, none load-bearing):

{
"v": 1,
"thinning": {
"enabled": false,
"promote": { // occurrence-based, never calendar-day
"p0": { "rate": 0.90, "occ": 10, "unprompted_tail": 5 },
"p1": { "rate": 0.85, "occ": 14 },
"p2": { "rate": 0.80, "occ": 21, "ucr": 0.75 },
"p3": { "occ": 30, "ucr": 0.80, "holiday": { "days": 7, "rate": 0.70 } }
},
"demote": { "rate_floor": 0.65, "window_occ": 7, "consecutive_misses": 3 },
"schedules": { "p1_fr": 2, "p2_vr": 3 } // FR2, VR3 averages
},
"decay": {
"enabled": false,
"onset_days": 30, "step_pct": 10, "cadence_days": 7, "floor_pct": 50
}
}

Per-chore override: nullable chores.fade_override jsonb (additive; the chore_recurrence_incentives precedent). Absent = household policy. A chore can be pinned out of thinning entirely ({"thinning":{"enabled":false}}).

Per-(member, chore) state (facts, not derivable — parent holds, promotion history, holidays): new table when the engine lands — member_chore_reinforcement_state(id, household_id, member_id, chore_id, phase check in ('p0','p1','p2','p3','graduated'), entered_phase_at, held_by_parent bool default false, last_holiday_at, occurrence_cursor, updated_at) + unique (member_id, chore_id); RLS read-household / write-parental; phase moves one step only (service + trigger twin).

Decision 4 — TokenBatch is hereby FROZEN

With Decisions 2–3, token_batches needs no new columns: decay bookkeeping uses existing fields; thinning never touches batches (it modulates earn-time credits). The schema as deployed in 20260612000002 is declared frozen. Open item 3 in rebuild-spec.md is resolved.

Decision 5 — determinism + gates

Fade computation is a pure function of (facts, policy, clock): local Drift and cloud must agree offline (LWW/union addendum applies). Ledger writes are idempotent — decay keyed refId = batchId:period, thinning transitions keyed by state row id + phase. A future SDK FadingService owns both mechanisms (runs on read-or-cron); dual gate as always (service authz AND RLS; trigger twins when the engine migration lands).

Decision 6 — the invariant-2 line (decay ≠ punishment)

Fading is never behavior-contingent removal: thinning only changes future earn frequency; decay (if ever enabled) is time/policy-based and never fires in response to a missed chore, a decline, or any behavior. Child-visible framing is positive at every transition ("you're building a real habit") and the graduation is celebrated. Any design that deducts tokens because of behavior violates invariant 2 and this ADR.

Explicit non-decisions

Parameter VALUES (Phase-2 clinical sign-off; the jsonc above is research defaults from the ABA literature) · the FadingService engine + UI (dossier build-order item 10; requires kudos + celebration rails first) · enabling decay at all · readiness signals from OT assessment (pluggable policy inputs).

Consequences / follow-ups

  1. LedgerEntryKind.fade + fade_policy + fade_override are one additive migration when the engine work starts (not now).
  2. Kudos (natural-reinforcer rail) and the celebration beats must precede thinning Phase 2 in the build order — fading without them is just a shrinking allowance.
  3. rebuild-spec.md ledger row added; open item 3 struck.
  4. Achievements/streaks must not key off ChoreKind/minAge as complexity proxies (clinical don't-do #3) — thinning readiness likewise.