Token economy — feature architecture
BUILT (2026-07-02) — E1–E5 complete. The full earn→spend loop is live: SDK + presentation layer. Parent reward/activity CRUD, gate editor, child 4-tab catalog browse, bounty claim, adult-direct / child-request→approve redeem, and redemption history all shipped. E5 added the chore
ApprovalPolicytoggle, activity affordability gate, Home error surfacing, andActivity.autoApproveRedemption. HS-4 resolved: earn lands inBucket.general(notEarningsSplitauto-split) — see Money & Envelopes. Source-of-truth for what/why is the clean rebuild design spec §1–6 (POC repodocs/rebuild/2026-06-11-clean-rebuild-design-spec.md); this page is the how it fits derived from the live SDK.
The loop in one idea
A chore is the only way tokens enter the economy; a reward or activity
is the only way they leave. Both crossings go through one human-authorized
gate — ApprovalService. Nothing else moves a token.
EARN ───────────────────────▶ LEDGER ───────────────────────▶ SPEND
chore done → submit append-only LedgerEntry request reward / activity
│ (give / save / spend buckets) │
▼ ▲ │ ▼
pending Approval(completion) │ │ pending Approval(redemption)
│ credit │ │ debit │
└──── parent approve ──▶ EarningsSplit.allocate parent approve ──┐
(or auto-policy) (give/save/spend) zero-floor debit ◀───────┘
→ Redemption
⛔ A token only ever moves inside ApprovalService.approve / autoApproveCompletion.
Requesting, submitting, rejecting, and cancelling NEVER touch the ledger.
The chore → activity link is one-directional and lives on a separate
ActivityGate row (requiredChoreId → Chore): completing a chore unlocks
an activity. A Chore has no field pointing at any reward or activity — it
does not know it gates anything.
1 · Entities & relationships
Load-bearing FKs
ActivityGate.activityId → ActivityandActivityGate.requiredChoreId → Chore— the only chore↔activity link, and it lives on the gate, not the chore.SpendRequest.targetId → Reward|Activity(disambiguated bytargetKind); no DB FK because it is polymorphic.Approval.refId → ChoreSubmission(kind=completion) or→ SpendRequest(kind=redemption).Redemption.spendRequestId → SpendRequest.LedgerEntry.refId → Approval.id— every token movement traces back to the approval that authorized it.Walletis not a table. It is a projection folded fromLedgerEntry(LedgerService.walletOf); same for goal progress (folded from the save bucket).
2 · The earn → spend flow
Key short-circuits and guards on the spend side:
activityGatingOFF ⇒ gates are a no-op — every activity is immediately requestable regardless of its gates (_enforceActivityGatesreturns early).- Affordability never blocks a request.
redemptionShortfallreportsmax(0, cost − spendBalance)for the UI; the debit's zero-floor (at approve time) is the only hard stop. AminTokenBalancegate is different — it locks access to the activity entirely. - Cancel / reject never touch the ledger (invariant 2, non-punitive):
cancelSpendRequest(owner or parent) andreject(parent) flip both theSpendRequestand its linkedApprovalto cancelled/rejected.
3 · Activity gating — the chore↔activity link (crisp)
A gate is a first-class ActivityGate row attached to one activity. An
activity can carry several gates; all must pass (AND) before the activity can
be requested. Two gate predicates exist, evaluated lazily at request time:
| Predicate | Field | Unlocks when… |
|---|---|---|
| Chore completion | requiredChoreId → Chore | the member has ≥ 1 approved ChoreCompletion for that chore |
| Min balance | minTokenBalance | the member's total balance (summed across all buckets) ≥ threshold |
Direction is one-way. The gate points at the chore; the Chore has no
back-reference and is unaware it unlocks anything. Rewards are never gated.
Reset cadence (RATIFIED — one-time-permanent for MVP-1, CB-06): the
chore-completion gate checks for any approved completion ever — once satisfied it
stays satisfied. There is no period scoping today (no daily/weekly re-lock);
daily/weekly re-lock is deferred to a future period field on ActivityGate.
Gate granularity is per-activity, household-wide (RATIFIED, CB-07; per-kid named
gates deferred). See §8.
Gate status visibility (agreed UI contract — CB-09): when the catalog browse UI
is built, a locked activity surfaces its locked reason to both the child and
the parent — "finish Tidy room" for a requiredChoreId gate, "needs N tokens"
for a minTokenBalance gate (neurodiversity-affirming clarity). It is a UI contract
(no SDK change) and applies when that surface ships.
4 · Invariants (enforced in SDK Service and schema)
- Append-only ledger.
LedgerEntryhas nocopyWith, no update/delete in any port/service/facade/SQL. Balances are always folded, never stored (spec §8.9). SQL twin: no UPDATE/DELETE policy onledger_entries. - Zero floor.
LedgerService.debitthrowsInsufficientBalanceExceptionbefore appending if it would take a bucket below zero. SQL twin:enforce_zero_floor()trigger. - No token moves until approve. Submitting a chore or requesting a redemption
only creates a pending
Approval. The single disbursement path isApprovalService.approve/autoApproveCompletion;reject/cancelnever touch the ledger. - Expectation pays zero.
Chore.tokenValueMUST be 0 whenkind = expectation; an approved expectation records aChoreCompletionwithtokenAmount 0and writes no ledger entry. - Gates are lazy & non-punitive. Evaluated only at request time; an unmet
gate throws
ActivityLockedbefore anySpendRequest/Approvalis written — it withholds access, it never deducts. - Earn split conserves tokens.
EarningsSplit.allocate(amount)distributes a credit across give/save/spend by integer math with spend absorbing the remainder, so the parts sum to exactlyamount(default 10/40/50). - Parental gate on disbursement. Only
kind.isParentalmembers resolve approvals; auto-policy is a parent's advance authorization (resolvedBy = auto_policy, attributed toautoApprovedByMemberId).
5 · Parent vs child surfaces
| Surface | Actor | SDK it drives | Built? |
|---|---|---|---|
| Reward catalog CRUD | Parent/Admin | createReward/updateReward/archiveReward/deleteReward | SDK ✓ · UI ✓ (E1) |
| Activity catalog CRUD | Parent/Admin | createActivity/updateActivity/archive/delete | SDK ✓ · UI ✓ (E2) |
| Gate editor (chore→activity) | Parent/Admin | createActivityGate/getActivityGates/deleteActivityGate | SDK ✓ · UI ✓ (E2) |
| Chore auto-approve toggle | Parent/Admin | Chore.approvalPolicy (manual/auto) | SDK ✓ · UI ✓ (E5) |
| Activity auto-approve redemption flag | Parent/Admin | Activity.autoApproveRedemption | SDK ✓ · UI ✓ (E5) |
| Earnings split / economy config | Parent/Admin | Household.split (EarningsSplit, now move-sheet defaults) | SDK ✓ · UI ✓ (RW-10 wizard) |
| Browse catalog | Child/Member | getRewards/getActivities (+ eligibility + lock visibility) | SDK ✓ · UI ✓ (E3a) |
| Bounty claim | Child/Member | claimBounty | SDK ✓ · UI ✓ (E3b) |
| Request reward / activity | Child/Member | requestRewardRedemption/requestActivityRedemption (+ redemptionShortfall) | SDK ✓ · UI ✓ (E3-redeem) |
| Cancel a pending request | Child or Parent | cancelSpendRequest | SDK ✓ · UI ✓ (E4) |
| Approve / reject queue | Parent (admin) | ApprovalService.approve/reject, watchPendingApprovals | SDK ✓ · UI ✓ |
| Redemption history | Both | getRedemptions/watchRedemptions | SDK ✓ · UI ✓ (E4) |
6 · POC ↔ rebuild delta (what the deep review surfaced)
The rebuild SDK is ahead of the POC on enforcement; the POC has UI the rebuild lacks. The notable field-level and modelling deltas:
| Area | POC (Chore_app) | Rebuild (rewhaven) | Action |
|---|---|---|---|
| Reward.description | SDK Reward has description: String? (and kind: RewardKind{activity,item}) | description RESTORED (CB-01); kind stays dropped (CB-05) | ✅ done — nullable description; rewards/activities are separate entities so kind is redundant |
| Activity.description | SDK Activity has description: String? | RESTORED (CB-01) — nullable description: String? | ✅ done |
| Activity.category | Legacy Activity had a typed ActivityCategory enum {entertainment, educational, social, outdoor, other} | free-text category: String? (no enum) | Ratified: free-text for MVP-1 (CB-04); typed enum deferred |
| Gating model | Activity.requiresChoreIds: List<String> inline and a per-kid ActivityGate{kidId, gateName, requiredChoreIds[]} — never enforced (Phase-2 gap) | first-class per-activity ActivityGate{activityId, requiredChoreId, minTokenBalance} — enforced in _enforceActivityGates, flag-gated | Rebuild ahead; ratified per-activity (CB-07) + one-time reset (CB-06) |
| Buckets | 4 buckets: give/save/spend + bank (unallocated landing); _mintTokens→bank, allocateFromBank/_transferBatches (not UI-exposed) | 3 buckets: give/save/spend; earn auto-splits at approve via EarningsSplit (no bank) | Coordinate with Money "Unallocated" (HS-4, awaiting sign-off) |
| Ledger | _mintTokens/_burnTokens FIFO over tokens batch rows | strict append-only LedgerEntry, balances folded, zero-floor in service + SQL | Rebuild ahead |
| Redemption | redeemReward (parent immediate debit) + SpendRequest/approveSpendRequest (no create-UI) | uniform request → pending Approval → approve → debit → Redemption, with cancelSpendRequest | Rebuild ahead |
| Auto-approve | none | ApprovalPolicy.auto (advance parental authorization, audited) | Rebuild ahead |
| UI | RedeemReward_Sheet, UseActivity_Sheet, Catalog_Page, History_Page, RewardForm_Page/ActivityForm_Page (forms exist, not wired to router), EconomyEditor_Sheet | none for reward/activity/gate/redemption | Build (§7) |
| tithe / give | Household.giveDestinationName; tithe transfer not built | give bucket exists; tithe flag is UI-only (no SDK behaviour) | Ratified: tithe stays UI-only (CB-08); give routes via EarningsSplit |
7 · Gap list & build order (proposed — not built)
The SDK is ready; this is a UI-only build over the existing facade. Proposed slice order (each is an independent vertical):
- Parent reward catalog — list + create/edit/archive over
createReward/updateReward/archiveReward. Thedescriptionfield is restored (CB-01) — surface it in the form (see §6). - Parent activity catalog — same shape over the
Activitymethods.descriptionis restored (CB-01);categorystays free-text (CB-04) — a plain text input, not a typed picker. - Gate editor (the chore→activity link) — the differentiator. Pick an
activity → add gates:
requiredChoreId(chore picker) and/orminTokenBalance(number). DrivescreateActivityGate/getActivityGates/deleteActivityGate. Surfaces the unlock condition in the Catalog. - Child browse / request — Catalog browse (
getRewards/getActivitieswith age + gate-status filtering) →requestRewardRedemption/requestActivityRedemption, showingredemptionShortfalland the locked state; pluscancelSpendRequest. - Redemption history —
getRedemptions/watchRedemptions, per member.
Approval-queue resolution of redemptions reuses the existing approval surface
(watchPendingApprovals already streams kind=redemption items).
Status: BUILT (2026-07-02) — E1–E5 complete. All 5 slices shipped; see §5 for per-surface status.
8 · Decisions — MVP-1 (ratified)
The pre-build questions below are ratified for MVP-1 (consolidated backlog CB-01, CB-04..CB-09). Each is reversible/additive, so the recommended default was applied in-loop.
descriptiononReward+Activity— RESTORED (CB-01). Both carry a nullabledescription: String?again (catalog forms need it; purely additive). ✅ shipped — model field +copyWith+ snake-case mapper +.g.dartregen + column migration.Activity.category= free-text (CB-04). Stayscategory: String?; a typedActivityCategoryenum (the POC's{entertainment, educational, social, outdoor, other}) is a deferred later refinement — free-text unblocks the form now and an enum can wrap it later.Reward.kind(activity|item) — stays dropped (CB-05). The rebuild already models rewards and activities as separate entities, so akindtag is redundant.- Gate reset cadence = one-time-permanent (CB-06). The chore-completion gate is satisfied by
any approved completion ever and stays satisfied. Daily/weekly re-lock is deferred to a
future
periodfield onActivityGate. - Gate granularity = per-activity, household-wide (CB-07).
ActivityGate.activityIdgates one activity for the whole household. Per-kid named gates (the POC pattern) are deferred. - Gate status visibility (CB-09) — agreed UI contract. When the catalog browse UI is built, a locked activity shows its locked reason ("finish Tidy room" for a chore gate, "needs N tokens" for a min-balance gate) to both the child and the parent — neurodiversity-affirming clarity. No SDK change; applies when that UI ships (see §3).
tithe= UI-only; give routes viaEarningsSplit(CB-08). Thetitheflag is not wired for MVP-1; the give share is configured purely throughEarningsSplit.give. A second control would be redundant.
Previously gated on user sign-off (now resolved)
- General / Unallocated (HS-4) ✅ RESOLVED (2026-07-01). Earn lands in
Bucket.general(100% credit; no auto-split).EarningsSplitis retained as move-sheet suggestion defaults. The full Unallocated/envelope generalization is deferred — see Money & Envelopes §Deferred for the remaining items. The earn loop diagram in §2 (EarningsSplit.allocate) is superseded: the live path is_approveCompletion→LedgerService.credit(bucket: Bucket.general).
Relationship to other features
- Catalog — the browse surface where rewards/activities/gated items appear to members.
- Activity gating — the differentiator stub this page makes concrete (gate model + enforcement).
- Money & Envelopes — the wallet/bucket/ledger substrate and the Unallocated-envelope reshape that touches the earn side of this loop.
- Authorization, personas & consent — the parental gate on approvals, supervised-action earn routing, and the child-PII consent gate on goals.
- Member profile — goal lifecycle (request/approve/complete).