SP‑C — Companion Durable Dewdrops (buddy-owned points) — Design Spec
Date: 2026-07-23
Status: Draft for owner review
Initiative: Onboarding + invite + account-management + multi-household (sub-project C of 4). Vision: docs/decisions/2026-07-22-onboarding-invite-account-management-vision.md (capability #4 = delete-and-merge). SP‑A (multi-household foundation) and SP‑B (code-first onboarding + auto-detect invite redemption) are SHIPPED to prod. Branch: feat/mvp1-personas-authz.
What SP‑C actually is (narrowed, owner 2026-07-23)
The vision's capability #4 is "delete a stray household and merge into another." During brainstorming the owner narrowed SP‑C to its foundational, independently-valuable piece and deferred the rest:
"we can defer, but the part that we can move on is moving the points to be associated with the Buddy instead of anything else."
SP‑C builds: make a companion's dewdrop balance a durable property of the buddy, decoupled from the volatile, household-scoped chore_completions count it is derived from today. This is the load-bearing prerequisite for the deferred delete-and-merge (a buddy can only survive a household delete / travel on transfer if its currency lives on the buddy, not on deletable household rows). It also fixes a standalone latent bug (below).
SP‑C explicitly defers (design notes preserved at the end of this doc, north-star for a later sub-project): the delete-and-merge UX, the guarded empty-only household delete, buddy transfer/import, orphan-child-on-erasure, and full GDPR account erasure.
The problem (and the latent bug this fixes)
Today the dewdrop balance is never stored — it is derived on every read and every spend:
CompanionService(packages/client_sdk/lib/src/services/companion_service.dart:139-141):earned = completions.length * kDewdropsPerCompletion(k = 1),spent = sum(companion_ledger.dewdrops_cost),balance = max(0, earned − spent).- The SQL twin
enforce_companion_zero_floor()(infra/supabase/migrations/20260718000100_companion_creature.sql:147-149) readsearned := count(chore_completions where member_id = …)on everycompanion_ledgerinsert.
Because earned is count(chore_completions), it is tied to household-scoped completion rows. Two consequences:
- Latent bug (in-household): any path that removes/rolls-over
chore_completions(daily/weekly rollover, an un-approval) silently drops a kid's earned dewdrops — and can make the zero-floor trigger reject a spend the kid had legitimately saved for. A kid saving toward a cosmetic can lose progress with no user action. - Blocks durability (cross-household): deleting the household cascades the completions → the balance collapses. The buddy's currency cannot survive a delete or travel on transfer.
Both dissolve once earned dewdrops are an append-only, buddy-owned fact instead of a live count.
Design
1. A durable, append-only companion earn ledger
Add a new append-only table companion_earn — the earn twin of the existing spend companion_ledger, mirroring its shape, RLS, and append-only discipline:
- Columns:
id uuid pk,member_id uuid(FK → household_members, cascade — see note),household_id uuid(FK → households, cascade),source_completion_id uuid(the chore completion that earned it — an idempotency key, NOT a cascading FK, so the earn survives the completion's later deletion/rollover),dewdrops_amount int not null check (> 0),created_at timestamptz. - Idempotency:
unique (member_id, source_completion_id)— one earn row per completion, ever. Re-running the credit is a no-op (on conflict do nothing). - RLS: identical to
companion_ledger— select for the kid's own rows or the household's parental members; insert pinned to the member's actual household (the I3 guard); no update/delete policies (append-only, likecompanion_ledger/ledger_entries).
source_completion_id is a plain uuid, not references chore_completions, precisely so a completion rollover/deletion cannot cascade-delete the earn. The earn is a permanent fact the moment it happens (token-ledger discipline).
2. Credit path — a trigger on chore completion
Earning stays triggered by a chore completion and walled from tokens (the A3/A4 invariant is preserved — dewdrops come from completions, never from ledger_entries). A new AFTER INSERT trigger on chore_completions appends one companion_earn row (dewdrops_amount = kDewdropsPerCompletion, on conflict (member_id, source_completion_id) do nothing). Server-side + idempotent, so it can never miss or double-count, and it does not require the member_companion row to pre-exist (earn accrues independently of whether the kid has opened their buddy yet).
3. Balance now reads from the earn ledger
- SQL:
enforce_companion_zero_floor()changes itsearnedsource fromcount(chore_completions …)tocoalesce(sum(dewdrops_amount),0) from companion_earn where member_id = …. The per-member advisory lock (I2 TOCTOU fix) and thespent + new.cost > earnedcheck are unchanged. - Dart:
CompanionServicereadsearned = sum(companion_earn)for that member instead ofcompletions.length * k.spent,balance = max(0, earned − spent), and the zero-floor are otherwise unchanged. A newStoragePortverb exposes the earn total (mirroring howevercompanion_ledgerspends are read); modeled in the in-memory / fake / cached / local adapters exactly like the spend ledger.
4. Migration & backfill (one-time, idempotent)
File-only migration under infra/supabase/migrations/, prod apply DEPLOY-GATED:
- Create
companion_earn+ indexes + RLS + grants. - Backfill: insert one
companion_earnrow per existingchore_completionsrow (source_completion_id = the completion id,dewdrops_amount = k),on conflict do nothing. This reproduces every current balance exactly (sum(earn) == count(completions) × kat apply time) so no kid loses or gains a dewdrop at cutover. - Install the
AFTER INSERTtrigger onchore_completions. - Switch
enforce_companion_zero_floor()to read fromcompanion_earn.
Ordering matters: backfill BEFORE switching the zero-floor source, so the floor never reads an empty earn table mid-migration.
5. Invariants preserved (unchanged)
- Dewdrops earned only from completions, never from tokens; never convertible; physically separate ledger.
- Append-only; zero-floored (both service + SQL trigger); the I2 advisory-lock and I3 household-pin guards intact.
member_companion(species/name/equipped) andcompanion_ledger(spends,unique(member_id, cosmetic_id)) are untouched.
Data flow
ChoreCompletion insert → trigger appends companion_earn (idempotent) → balance (CompanionService / zero-floor trigger) = sum(companion_earn) − sum(companion_ledger), zero-floored. Presentation path unchanged: Bloc → Repository → Client facade → CompanionService → adapter; the companion cubit reads the same balance shape it does today.
Error handling
- Typed errors only (
on <SpecificException>; never barecatch, neverError). The existingInsufficientDewdropsException(zero-floor) is unchanged. - Trigger uses
on conflict do nothing— a retried/duplicated completion insert never double-credits and never raises. - Migration is idempotent (
on conflict do nothingon backfill) — safe to re-run.
Testing
- SQL/RPC (SDK cloud tests via the fake adapter): earn accrues one row per completion; duplicate completion → no double credit; a completion deletion does not drop the earn (durability); zero-floor now reads from
companion_earn(a spend beyondearnedis rejected; a spend within a preserved balance succeeds even after its completions are gone). - SDK service:
balance = earned(earn ledger) − spent, zero-floored; walling unchanged (no token influence). - Migration parity: post-backfill balance == pre-migration derived balance for seeded fixtures (no kid loses progress).
- Reuse the SDK test doubles + model
companion_earnin the in-memory/fake/cached/local adapters likecompanion_ledger. Baselines must not drop (SDK ~1208, app ~838 at branch head).
Global constraints
- One data path (Bloc → Repository → Client facade →
CompanionService→ adapter); presentation never imports drift/supabase. - Invariants in service AND schema (the zero-floor + walling live in both
CompanionServiceand the SQL trigger — both must move together). - Migration file-only under
infra/supabase/migrations/; prod apply tobgedvvmihygwxhjxlvfuis DEPLOY-GATED. All tests use the in-memory/fake adapters. - FVM only; no brand strings; all copy via
Strings; typed errors only.
Scope boundary
In SP‑C: companion_earn append-only earn ledger + idempotent completion trigger + zero-floor/service switch to the durable earn source + backfill migration (deploy-gated) + adapter modeling + tests.
NOT in SP‑C (deferred — see north-star below): any delete-and-merge UX, guarded household delete, buddy transfer/import, orphan-child-on-erasure, cross-household buddy identity, full GDPR account erasure. SP‑C does not change member_companion's household FK — full survive-household-deletion still needs the deferred buddy-identity work; SP‑C makes the currency durable-and-portable so that later work has something to carry.
Deferred — delete-and-merge north-star (design notes, for a later sub-project)
Preserved from the 2026-07-23 brainstorm so the direction isn't lost:
- Buddy is a durable, child-owned entity — it carries cosmetics, customizations, and (via SP‑C) its dewdrop balance. It belongs to the child's identity, not the household/member row.
- Deleting/erasing a household never deletes a child's account — the child is orphaned (no household) but keeps their account, buddy, cosmetics, and dewdrops. (Activates when child accounts + cross-household identity exist; child accounts are COPPA-gated today, shadow kids have no login.)
- Delete-and-merge = pure delete, buddy exempt. A household can only be deleted once emptied (no other members, no user-created content: chores/rewards/activities/rooms/goals) — the app blocks delete while data remains and points at the existing per-item delete affordances. Companions are moved out first via buddy transfer/import (map a member's buddy → an existing member in another household; cosmetics + the now-durable dewdrops travel with it; target-already-has-buddy → union cosmetics).
- GDPR framing: the self-service "clear my data, then delete my household" path is the foundation for right-to-erasure; children's accounts/buddies are intentionally preserved (orphaned), not erased unless a child's own account is separately, deliberately deleted.
- Dependency: the automatic buddy-follows-child-across-households behavior needs the cross-household child identity that SP‑A deferred (
kids.identity_id, the divorce-clone continuous-identity direction). SP‑C's durable dewdrops + a later manual buddy-transfer are the bridge until then.