Skip to main content

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 ApprovalPolicy toggle, activity affordability gate, Home error surfacing, and Activity.autoApproveRedemption. HS-4 resolved: earn lands in Bucket.general (not EarningsSplit auto-split) — see Money & Envelopes. Source-of-truth for what/why is the clean rebuild design spec §1–6 (POC repo docs/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 gateApprovalService. 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 (requiredChoreIdChore): 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 → Activity and ActivityGate.requiredChoreId → Chore — the only chore↔activity link, and it lives on the gate, not the chore.
  • SpendRequest.targetId → Reward|Activity (disambiguated by targetKind); 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.
  • Wallet is not a table. It is a projection folded from LedgerEntry (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:

  • activityGating OFF ⇒ gates are a no-op — every activity is immediately requestable regardless of its gates (_enforceActivityGates returns early).
  • Affordability never blocks a request. redemptionShortfall reports max(0, cost − spendBalance) for the UI; the debit's zero-floor (at approve time) is the only hard stop. A minTokenBalance gate is different — it locks access to the activity entirely.
  • Cancel / reject never touch the ledger (invariant 2, non-punitive): cancelSpendRequest (owner or parent) and reject (parent) flip both the SpendRequest and its linked Approval to cancelled/rejected.

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:

PredicateFieldUnlocks when…
Chore completionrequiredChoreId → Chorethe member has ≥ 1 approved ChoreCompletion for that chore
Min balanceminTokenBalancethe 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)

  1. Append-only ledger. LedgerEntry has no copyWith, no update/delete in any port/service/facade/SQL. Balances are always folded, never stored (spec §8.9). SQL twin: no UPDATE/DELETE policy on ledger_entries.
  2. Zero floor. LedgerService.debit throws InsufficientBalanceException before appending if it would take a bucket below zero. SQL twin: enforce_zero_floor() trigger.
  3. No token moves until approve. Submitting a chore or requesting a redemption only creates a pending Approval. The single disbursement path is ApprovalService.approve / autoApproveCompletion; reject/cancel never touch the ledger.
  4. Expectation pays zero. Chore.tokenValue MUST be 0 when kind = expectation; an approved expectation records a ChoreCompletion with tokenAmount 0 and writes no ledger entry.
  5. Gates are lazy & non-punitive. Evaluated only at request time; an unmet gate throws ActivityLocked before any SpendRequest/Approval is written — it withholds access, it never deducts.
  6. 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 exactly amount (default 10/40/50).
  7. Parental gate on disbursement. Only kind.isParental members resolve approvals; auto-policy is a parent's advance authorization (resolvedBy = auto_policy, attributed to autoApprovedByMemberId).

5 · Parent vs child surfaces

SurfaceActorSDK it drivesBuilt?
Reward catalog CRUDParent/AdmincreateReward/updateReward/archiveReward/deleteRewardSDK ✓ · UI ✓ (E1)
Activity catalog CRUDParent/AdmincreateActivity/updateActivity/archive/deleteSDK ✓ · UI ✓ (E2)
Gate editor (chore→activity)Parent/AdmincreateActivityGate/getActivityGates/deleteActivityGateSDK ✓ · UI ✓ (E2)
Chore auto-approve toggleParent/AdminChore.approvalPolicy (manual/auto)SDK ✓ · UI ✓ (E5)
Activity auto-approve redemption flagParent/AdminActivity.autoApproveRedemptionSDK ✓ · UI ✓ (E5)
Earnings split / economy configParent/AdminHousehold.split (EarningsSplit, now move-sheet defaults)SDK ✓ · UI ✓ (RW-10 wizard)
Browse catalogChild/MembergetRewards/getActivities (+ eligibility + lock visibility)SDK ✓ · UI ✓ (E3a)
Bounty claimChild/MemberclaimBountySDK ✓ · UI ✓ (E3b)
Request reward / activityChild/MemberrequestRewardRedemption/requestActivityRedemption (+ redemptionShortfall)SDK ✓ · UI ✓ (E3-redeem)
Cancel a pending requestChild or ParentcancelSpendRequestSDK ✓ · UI ✓ (E4)
Approve / reject queueParent (admin)ApprovalService.approve/reject, watchPendingApprovalsSDK ✓ · UI ✓
Redemption historyBothgetRedemptions/watchRedemptionsSDK ✓ · 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:

AreaPOC (Chore_app)Rebuild (rewhaven)Action
Reward.descriptionSDK 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.descriptionSDK Activity has description: String?RESTORED (CB-01) — nullable description: String?✅ done
Activity.categoryLegacy 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 modelActivity.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-gatedRebuild ahead; ratified per-activity (CB-07) + one-time reset (CB-06)
Buckets4 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 rowsstrict append-only LedgerEntry, balances folded, zero-floor in service + SQLRebuild ahead
RedemptionredeemReward (parent immediate debit) + SpendRequest/approveSpendRequest (no create-UI)uniform request → pending Approval → approve → debit → Redemption, with cancelSpendRequestRebuild ahead
Auto-approvenoneApprovalPolicy.auto (advance parental authorization, audited)Rebuild ahead
UIRedeemReward_Sheet, UseActivity_Sheet, Catalog_Page, History_Page, RewardForm_Page/ActivityForm_Page (forms exist, not wired to router), EconomyEditor_Sheetnone for reward/activity/gate/redemptionBuild (§7)
tithe / giveHousehold.giveDestinationName; tithe transfer not builtgive 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):

  1. Parent reward catalog — list + create/edit/archive over createReward/updateReward/archiveReward. The description field is restored (CB-01) — surface it in the form (see §6).
  2. Parent activity catalog — same shape over the Activity methods. description is restored (CB-01); category stays free-text (CB-04) — a plain text input, not a typed picker.
  3. Gate editor (the chore→activity link) — the differentiator. Pick an activity → add gates: requiredChoreId (chore picker) and/or minTokenBalance (number). Drives createActivityGate/getActivityGates/ deleteActivityGate. Surfaces the unlock condition in the Catalog.
  4. Child browse / request — Catalog browse (getRewards/getActivities with age + gate-status filtering) → requestRewardRedemption/ requestActivityRedemption, showing redemptionShortfall and the locked state; plus cancelSpendRequest.
  5. Redemption historygetRedemptions/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.

  • description on Reward + Activity — RESTORED (CB-01). Both carry a nullable description: String? again (catalog forms need it; purely additive). ✅ shipped — model field + copyWith + snake-case mapper + .g.dart regen + column migration.
  • Activity.category = free-text (CB-04). Stays category: String?; a typed ActivityCategory enum (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 a kind tag 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 period field on ActivityGate.
  • Gate granularity = per-activity, household-wide (CB-07). ActivityGate.activityId gates 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 via EarningsSplit (CB-08). The tithe flag is not wired for MVP-1; the give share is configured purely through EarningsSplit.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). EarningsSplit is 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 _approveCompletionLedgerService.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).