Skip to main content

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) reads earned := count(chore_completions where member_id = …) on every companion_ledger insert.

Because earned is count(chore_completions), it is tied to household-scoped completion rows. Two consequences:

  1. 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.
  2. 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, like companion_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 its earned source from count(chore_completions …) to coalesce(sum(dewdrops_amount),0) from companion_earn where member_id = …. The per-member advisory lock (I2 TOCTOU fix) and the spent + new.cost > earned check are unchanged.
  • Dart: CompanionService reads earned = sum(companion_earn) for that member instead of completions.length * k. spent, balance = max(0, earned − spent), and the zero-floor are otherwise unchanged. A new StoragePort verb exposes the earn total (mirroring however companion_ledger spends 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:

  1. Create companion_earn + indexes + RLS + grants.
  2. Backfill: insert one companion_earn row per existing chore_completions row (source_completion_id = the completion id, dewdrops_amount = k), on conflict do nothing. This reproduces every current balance exactly (sum(earn) == count(completions) × k at apply time) so no kid loses or gains a dewdrop at cutover.
  3. Install the AFTER INSERT trigger on chore_completions.
  4. Switch enforce_companion_zero_floor() to read from companion_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) and companion_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 bare catch, never Error). The existing InsufficientDewdropsException (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 nothing on 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 beyond earned is 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_earn in the in-memory/fake/cached/local adapters like companion_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 CompanionService and the SQL trigger — both must move together).
  • Migration file-only under infra/supabase/migrations/; prod apply to bgedvvmihygwxhjxlvfu is 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.