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.fadeStatewas 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
LedgerEntryKind.fade+fade_policy+fade_overrideare one additive migration when the engine work starts (not now).- 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.
rebuild-spec.mdledger row added; open item 3 struck.- Achievements/streaks must not key off
ChoreKind/minAgeas complexity proxies (clinical don't-do #3) — thinning readiness likewise.