Skip to main content

SP‑C — Companion Durable Dewdrops (buddy-owned points) — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make a companion's earned dewdrops a durable, append-only, buddy-owned fact (companion_earn) instead of the live count(chore_completions) it is derived from today — so a kid's saved dewdrops survive completion rollover/deletion and are portable for the deferred delete-and-merge.

Architecture: Add an append-only companion_earn ledger (one row per chore completion, idempotent on (member_id, source_completion_id)). It is credited at the storage layer — by a DB AFTER INSERT trigger on chore_completions in cloud, and by each local adapter's insertCompletion in in-memory/Drift mode. earned (in both CompanionService and the SQL zero-floor trigger) switches from count(chore_completions) to sum(companion_earn). A deploy-gated backfill seeds one earn row per existing completion so no balance changes at cutover.

Tech Stack: Flutter 3.44 / Dart 3.9 (FVM: fvm dart, fvm flutter); pub workspace; Supabase (SQL trigger + RLS); Drift (local persistence, build_runner codegen); the SDK adapter stack (in-memory / FakePort / local-Drift / cached / cloud).

Global Constraints

  • One data path: Bloc → Repository → Client facade → CompanionService → adapter. Presentation NEVER imports drift/supabase.
  • Invariants in service AND schema: dewdrops are earned ONLY from chore completions, NEVER from ledger_entries (walling — the A3/A4 guard); never convertible to tokens; append-only; zero-floored. The zero-floor + walling live in BOTH CompanionService and the SQL trigger — both move together.
  • Idempotency: one earn row per completion, ever — unique (member_id, source_completion_id) + on conflict do nothing. No completion may double-credit.
  • Durability: companion_earn.source_completion_id is a plain uuid idempotency key, NOT a cascading FK to chore_completions — so a completion's later deletion/rollover cannot cascade-delete the earn.
  • Typed errors ONLY: on <SpecificException> — never bare catch (e), never catch Error. InsufficientDewdropsException (zero-floor) is unchanged.
  • Migration file-only under infra/supabase/migrations/; prod apply to bgedvvmihygwxhjxlvfu is DEPLOY-GATED (owner pre-authorized for SP‑C). All SDK tests use the in-memory/fake adapters.
  • No app change: CompanionView.dewdropsBalance/Earned/Spent field shapes are unchanged; only the derivation inside CompanionService changes. Do NOT touch app/.
  • Scope: dewdrops only. lifetimeCompletions/growth-stage stay derived from getCompletions (out of scope — note only). No delete-and-merge / transfer / anything from the deferred north-star.
  • FVM only; no brand strings; baselines must not drop (SDK ~1208, app ~838 at branch head).

Reference anchors (verified — implementers may re-read)

  • CompanionService packages/client_sdk/lib/src/services/companion_service.dart: earned = completions.length * kDewdropsPerCompletion at lines 139 & 241 (identical in purchaseCosmetic and _viewOf); spent = ledger.fold(...dewdropsCost); balance = math.max(0, earned - spent). Reads _storage.getCompletions(household.id, memberId:) (earned + lifetimeCompletions) and _storage.getCompanionLedgerEntries(household.id, memberId:) (spent). Returns CompanionView{dewdropsEarned, dewdropsSpent, dewdropsBalance, lifetimeCompletions, stage, ownedCosmeticIds, messiness} (lines 254-266).
  • kDewdropsPerCompletion = 1packages/client_sdk/lib/src/models/companion.dart:8.
  • StoragePort packages/client_sdk/lib/src/adapters/adapter.dart: getCompletions(householdId, {memberId, choreId}) (219-223), insertCompletion(ChoreCompletion) (225); getCompanionLedgerEntries(householdId, {memberId}) (391-394), insertCompanionLedgerEntry(CompanionLedgerEntry) (399-401); getCompanion/insertCompanion/updateCompanion (380-386).
  • Completion insert path (ONLY one): ApprovalService._approveCompletion_storage.insertCompletion(ChoreCompletion(...)) at approval_service.dart:447-455.
  • Adapters (spend ledger + completions today):
    • in-memory memory/in_memory_storage_adapter.dart: _completions map (49, insert 346, get 336); _companionLedger map (66, insert 695, get 683).
    • FakePort test/support/fake_port.dart: public completions map (17, insert 291); companionLedgerEntries map (35, insert 620).
    • local Drift local/local_storage_adapter.dart: completions insert 421 / get 404 / mapper _completionFromRow 1289; companion ledger insert 1062 / get 1035. Drift tables in local/local_database.dart: ChoreCompletions (351), MemberCompanions (729), CompanionLedgerRows (757, UNIQUE(memberId, cosmeticId)).
    • cached cached/cached_storage_adapter.dart: insertCompletion write-through (588: durable then cache); insertCompanionLedgerEntry (972); hydration loads completions (134) + companionLedgerEntries (146); _resyncAll (250) does NOT include completions/companion_ledger.
    • cloud cloud/supabase_companion.dart: mixin CompanionStore; companion_ledger via db.selectEq/db.insert; codec 59-95.
  • Models models/companion.dart: Companion (107), CompanionLedgerEntry (152: id/householdId/memberId/cosmeticId/dewdropsCost/createdAt, immutable), CompanionView (182).
  • SQL zero-floor infra/supabase/migrations/20260718000100_companion_creature.sql:132-168enforce_companion_zero_floor() BEFORE INSERT on companion_ledger; earned := count(chore_completions where member_id=new.member_id) (147-149); spent := sum(companion_ledger.dewdrops_cost) (151-153); per-member pg_advisory_xact_lock (145). member_companion/companion_ledger/self_member_ids() RLS defined in this same migration.
  • Tests test/companion_service_test.dart (seeds via port.insertCompletion, helper seedCompletions lines 22-38), companion_model_test.dart, companion_port_test.dart, companion_facade_test.dart, cloud/companion_routing_test.dart, cloud/companion_rls_live_test.dart. Seed factory packages/client_sdk_testing/lib/src/seed_factories.dart: seedChoreCompletion (180); no companion-ledger factory.

Task 1: Migration — companion_earn ledger + trigger + zero-floor switch + backfill (file-only, DEPLOY-GATED)

Files:

  • Create: infra/supabase/migrations/20260723000200_companion_durable_dewdrops.sql

Interfaces (produced — the storage contract later tasks mirror):

  • Table public.companion_earn (id uuid pk, member_id uuid, household_id uuid, source_completion_id uuid, dewdrops_amount int check(>0), created_at timestamptz), unique(member_id, source_completion_id).

  • AFTER INSERT trigger on chore_completions appends one companion_earn row (dewdrops_amount = 1, on conflict do nothing).

  • enforce_companion_zero_floor() reads earned := sum(companion_earn.dewdrops_amount where member_id=…).

  • Step 1: Write the migration SQL

Create infra/supabase/migrations/20260723000200_companion_durable_dewdrops.sql:

-- SP-C: durable buddy-owned dewdrops. Earned dewdrops become an append-only
-- companion_earn ledger (one row per chore completion) instead of a live
-- count(chore_completions). source_completion_id is an IDEMPOTENCY KEY, NOT a
-- cascading FK — so a completion's later rollover/deletion never drops the
-- earn. Walling (earn from completions, never ledger_entries) + append-only +
-- zero-floor invariants are preserved; the zero-floor's earned source switches
-- to companion_earn. Backfill reproduces every current balance exactly.

-- ── companion_earn (APPEND-ONLY: insert + select policies ONLY) ──────────────
create table public.companion_earn (
id uuid primary key default gen_random_uuid(),
member_id uuid not null
references public.household_members (id) on delete cascade,
household_id uuid not null
references public.households (id) on delete cascade,
-- Idempotency key ONLY (deliberately NOT references chore_completions): the
-- earn is a permanent fact that must survive the completion's deletion.
source_completion_id uuid not null,
dewdrops_amount int not null check (dewdrops_amount > 0),
created_at timestamptz not null default now(),
unique (member_id, source_completion_id)
);
create index companion_earn_household_id_idx on public.companion_earn (household_id);
create index companion_earn_member_id_idx on public.companion_earn (member_id);

alter table public.companion_earn enable row level security;

-- RLS mirrors companion_ledger exactly (self rows or parental household read;
-- insert pinned to the member's actual household — the I3 guard).
create policy companion_earn_select on public.companion_earn
for select to authenticated
using (
member_id in (select public.self_member_ids())
or household_id in (select public.parental_household_ids())
);
create policy companion_earn_insert on public.companion_earn
for insert to authenticated
with check (
member_id in (select public.self_member_ids())
and household_id = (
select hm.household_id from public.household_members hm
where hm.id = member_id
)
);
-- No update/delete policies — append-only, like companion_ledger / ledger_entries.

-- ── Credit trigger: one earn row per chore completion (idempotent) ───────────
-- SECURITY DEFINER so the credit runs regardless of the inserting role's RLS;
-- pinned search_path per house convention.
create or replace function public.credit_companion_earn()
returns trigger
language plpgsql security definer set search_path = 'public' as $$
begin
insert into public.companion_earn
(member_id, household_id, source_completion_id, dewdrops_amount)
values (new.member_id, new.household_id, new.id, 1)
on conflict (member_id, source_completion_id) do nothing;
return new;
end;
$$;
revoke execute on function public.credit_companion_earn() from public, anon, authenticated;

create trigger chore_completions_credit_companion_earn
after insert on public.chore_completions
for each row execute function public.credit_companion_earn();

-- ── Backfill: reproduce every current balance exactly (idempotent) ───────────
-- One earn row per existing completion → sum(companion_earn) == count(completions)
-- at cutover, so no kid loses or gains a dewdrop. Safe to re-run.
insert into public.companion_earn
(member_id, household_id, source_completion_id, dewdrops_amount)
select cc.member_id, cc.household_id, cc.id, 1
from public.chore_completions cc
on conflict (member_id, source_completion_id) do nothing;

-- ── Switch the zero-floor's earned source to companion_earn ──────────────────
create or replace function public.enforce_companion_zero_floor()
returns trigger
language plpgsql
as $$
declare
earned int;
spent int;
begin
perform pg_advisory_xact_lock(hashtext(new.member_id::text));

select coalesce(sum(dewdrops_amount), 0) into earned
from public.companion_earn
where member_id = new.member_id;

select coalesce(sum(dewdrops_cost), 0) into spent
from public.companion_ledger
where member_id = new.member_id;

if spent + new.dewdrops_cost > earned then
raise exception
'companion zero floor violated: member % would have spent % of % earned',
new.member_id, spent + new.dewdrops_cost, earned
using errcode = 'check_violation';
end if;

return new;
end;
$$;

-- LIVE SMOKE PROBES (post-apply, DEPLOY-GATED):
-- 1. select count(*) from companion_earn; -- == count(chore_completions) after backfill
-- 2. insert a chore_completion (via app approve) -> companion_earn gains exactly 1 row for that member
-- 3. delete that chore_completion row -> companion_earn row REMAINS (durability)
-- 4. a companion_ledger purchase beyond sum(companion_earn) still raises the zero-floor
-- 5. per-member: sum(companion_earn) - sum(companion_ledger.dewdrops_cost) == the balance the app shows
  • Step 2: Review the SQL against the existing companion migration

Re-read 20260718000100_companion_creature.sql. Verify: (a) companion_earn RLS mirrors companion_ledger (self/parental select, household-pinned insert, no update/delete); (b) source_completion_id is NOT an FK to chore_completions; (c) the backfill count equals count(chore_completions); (d) the zero-floor keeps the advisory lock + spent calc, only swapping earned's source; (e) SECURITY DEFINER + pinned search_path + revoked execute on the trigger fn. No automated SQL test (contract tested via the SDK fake in later tasks). Do NOT apply to prod.

  • Step 3: Commit
git add infra/supabase/migrations/20260723000200_companion_durable_dewdrops.sql
git commit -m "feat(sp-c): companion_earn durable ledger + credit trigger + zero-floor switch (file-only, deploy-gated)"

Task 2: SDK — CompanionEarnEntry model + getCompanionEarnEntries verb + earn-on-insertCompletion across all adapters

Files:

  • Modify: packages/client_sdk/lib/src/models/companion.dart (add CompanionEarnEntry)
  • Modify: packages/client_sdk/lib/src/adapters/adapter.dart (add the abstract verb)
  • Modify: packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart
  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_companion.dart
  • Modify: packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart + local/local_database.dart (+ regen local_database.g.dart)
  • Modify: packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart
  • Modify: packages/client_sdk/test/support/fake_port.dart
  • Modify: packages/client_sdk/lib/client_sdk.dart (export CompanionEarnEntry)
  • Test: packages/client_sdk/test/companion_earn_adapter_test.dart

Interfaces:

  • Consumes: Task 1's companion_earn contract.
  • Produces:
    • class CompanionEarnEntry { final String id; final String memberId; final String householdId; final String sourceCompletionId; final int dewdropsAmount; final DateTime? createdAt; }
    • StoragePort.getCompanionEarnEntries(String householdId, {String? memberId}) → Future<List<CompanionEarnEntry>>
    • Every LOCAL adapter's insertCompletion ALSO appends one idempotent companion_earn row (keyed on (memberId, sourceCompletionId=completion.id)); the CLOUD adapter's insertCompletion is unchanged (the DB trigger credits it).

This is one compile unit — adding the abstract verb forces every adapter to implement it, so all adapters change together.

  • Step 1: Add the CompanionEarnEntry model

In packages/client_sdk/lib/src/models/companion.dart, beside CompanionLedgerEntry:

/// An append-only EARN of dewdrops — one per chore completion (SP-C). The earn
/// twin of [CompanionLedgerEntry] (spends). Durable + buddy-owned: it survives
/// its source completion's deletion so a kid's saved dewdrops are never lost.
class CompanionEarnEntry {
const CompanionEarnEntry({
required this.id,
required this.memberId,
required this.householdId,
required this.sourceCompletionId,
this.dewdropsAmount = kDewdropsPerCompletion,
this.createdAt,
});

final String id;
final String memberId;
final String householdId;
final String sourceCompletionId;
final int dewdropsAmount;
final DateTime? createdAt;
}
  • Step 2: Add the abstract verb to StoragePort

In adapter.dart, beside getCompanionLedgerEntries (~391):

/// Every durable dewdrop EARN for [householdId] (optionally one member).
/// Append-only; the SP-C earn twin of [getCompanionLedgerEntries]. Balance =
/// sum(earn) − sum(ledger spends), zero-floored.
Future<List<CompanionEarnEntry>> getCompanionEarnEntries(
String householdId, {
String? memberId,
});
  • Step 3: Write the failing adapter conformance test

Create packages/client_sdk/test/companion_earn_adapter_test.dart. Drive the in-memory adapter: inserting a completion appends exactly one earn; inserting the SAME completion id again does not double-credit; getCompanionEarnEntries filters by member. Use await expectLater(...) for any async throw.

import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk/src/adapters/memory/in_memory_storage_adapter.dart';
import 'package:test/test.dart';

void main() {
late InMemoryStorageAdapter port;
setUp(() => port = InMemoryStorageAdapter());

ChoreCompletion completion(String id, {String member = 'c1'}) => ChoreCompletion(
id: id, householdId: 'h1', choreId: 'chore-1',
memberId: member, completedAt: DateTime(2026, 7, 23),
);

test('insertCompletion credits one companion_earn per completion', () async {
await port.insertCompletion(completion('comp-1'));
await port.insertCompletion(completion('comp-2'));
final earn = await port.getCompanionEarnEntries('h1', memberId: 'c1');
expect(earn.length, 2);
expect(earn.fold<int>(0, (s, e) => s + e.dewdropsAmount), 2);
});

test('re-inserting the same completion does not double-credit', () async {
await port.insertCompletion(completion('comp-1'));
await port.insertCompletion(completion('comp-1'));
final earn = await port.getCompanionEarnEntries('h1', memberId: 'c1');
expect(earn.length, 1);
});

test('getCompanionEarnEntries filters by member', () async {
await port.insertCompletion(completion('comp-1', member: 'c1'));
await port.insertCompletion(completion('comp-2', member: 'c2'));
expect((await port.getCompanionEarnEntries('h1', memberId: 'c1')).length, 1);
expect((await port.getCompanionEarnEntries('h1')).length, 2);
});
}

Run: cd packages/client_sdk && fvm dart test test/companion_earn_adapter_test.dart → FAIL (verb + earn-append absent).

  • Step 4: Implement in-memory + FakePort

In in_memory_storage_adapter.dart: add final Map<String, CompanionEarnEntry> _companionEarn = {};. In insertCompletion (346), after storing the completion, append an earn keyed idempotently on completion.id:

@override
Future<ChoreCompletion> insertCompletion(ChoreCompletion completion) async {
_completions[completion.id] = completion;
// SP-C: credit one durable earn per completion (the local twin of the DB
// trigger). Idempotent on the source completion id.
_companionEarn.putIfAbsent(
'${completion.memberId}:${completion.id}',
() => CompanionEarnEntry(
id: 'earn-${completion.id}',
memberId: completion.memberId,
householdId: completion.householdId,
sourceCompletionId: completion.id,
),
);
return completion;
}

@override
Future<List<CompanionEarnEntry>> getCompanionEarnEntries(
String householdId, {
String? memberId,
}) async =>
_companionEarn.values
.where((e) => e.householdId == householdId &&
(memberId == null || e.memberId == memberId))
.toList();

Mirror the same in test/support/fake_port.dart (public companionEarn map + insertCompletion appends + getCompanionEarnEntries).

Run the test → PASS for the in-memory/fake path.

  • Step 5: Cloud adapter (supabase_companion.dart) — add getCompanionEarnEntries reading companion_earn via db.selectEq('companion_earn', {'household_id': householdId, if (memberId != null) 'member_id': memberId}) mapped through a snake_case codec (mirror the companion_ledger codec at 59-95: id, member_id, household_id, source_completion_id, dewdrops_amount, created_at). The cloud insertCompletion is UNCHANGED — the DB trigger credits the earn. Add import for the model.

  • Step 6: Local Drift adapter + codegen — in local/local_database.dart add a CompanionEarnRows table (@DataClassName('CompanionEarnRow')): id TEXT pk, householdId TEXT (FK → Households ON DELETE CASCADE), memberId TEXT, sourceCompletionId TEXT, dewdropsAmount INT default 1, createdAt DateTime nullable, with a UNIQUE(memberId, sourceCompletionId) index; bump the Drift schemaVersion + add the onUpgrade step creating the table. In local_storage_adapter.dart: insertCompletion (421) ALSO inserts a CompanionEarnRowsCompanion.insert(...) idempotently (onConflict: DoNothing() on the unique index) keyed on the completion id; add getCompanionEarnEntries (query _db.companionEarnRows filtered) + a _companionEarnFromRow mapper. Regenerate Drift with the scoped build filter to avoid clobbering hand-maintained .g.dart files:

cd packages/client_sdk && fvm dart run build_runner build --delete-conflicting-outputs \
--build-filter "lib/src/adapters/local/local_database.g.dart"

After regen, git status — if router.gr.dart / bloc state.g.dart were touched, restore them from HEAD (known Drift-regen clobber; see the drift-regen notes). Verify local_database.g.dart compiles.

  • Step 7: Cached adapter (cached_storage_adapter.dart)insertCompletion is already write-through (_durable.insertCompletion then _cache.insertCompletion, 588); because BOTH the durable (local-Drift or cloud) and the cache (in-memory) now credit earn in their own insertCompletion, no cached change is needed for crediting. Add getCompanionEarnEntries delegating to _cache (served from cache like the ledger), and load companion_earn in the hydration path beside companionLedgerEntries (146) so a cold cache is populated. (Do NOT add it to _resyncAll — consistent with completions/companion_ledger being absent there.)

  • Step 8: Export + run full suite — export CompanionEarnEntry from client_sdk.dart. Run cd packages/client_sdk && fvm dart test → green (baseline ~1208 + new). Fix any adapter that fails to implement the verb (compile) before proceeding.

  • Step 9: Commit

git add packages/client_sdk/lib packages/client_sdk/test/support/fake_port.dart packages/client_sdk/test/companion_earn_adapter_test.dart
git commit -m "feat(sp-c): companion_earn model + getCompanionEarnEntries + earn-on-completion across adapters"

Task 3: CompanionService reads earned from the earn ledger + tests

Files:

  • Modify: packages/client_sdk/lib/src/services/companion_service.dart
  • Test: packages/client_sdk/test/companion_service_test.dart (extend)

Interfaces:

  • Consumes: StoragePort.getCompanionEarnEntries (Task 2).

  • Produces: earned in CompanionView derived from sum(companion_earn) (not completions.length); lifetimeCompletions/stage unchanged (still from getCompletions).

  • Step 1: Write the failing service tests (extend companion_service_test.dart):

    • Durability: seed 5 completions (→ earned 5), buy a 3-cost cosmetic (balance 2). Then clear the port's completions map without clearing earn (mirrors prod: completion deleted, earn preserved). Assert getCompanion(...).dewdropsBalance == 2 still (today it would drop to floored 0). This is the regression that proves durability.
    • Balance from earn: with N earn entries and M spent, dewdropsEarned == N, dewdropsBalance == max(0, N − M).
    • lifetimeCompletions/stage unchanged: still reflects getCompletions().length. Use the existing seedCompletions helper (it calls port.insertCompletion, which now also credits earn). For the durability test, after seeding, do port.completions.clear() (FakePort exposes it) and assert the earn-derived balance holds.

Run → FAIL (service still uses completions.length).

  • Step 2: Switch earned in CompanionService — at both sites (lines ~139 and ~241), replace:
final earned = completions.length * kDewdropsPerCompletion;

with a read of the earn ledger (add the fetch beside the existing getCompanionLedgerEntries/getCompletions reads):

final earn = await _storage.getCompanionEarnEntries(household.id, memberId: memberId);
final earned = earn.fold<int>(0, (sum, e) => sum + e.dewdropsAmount);

Keep completions (from getCompletions) for lifetimeCompletions + stageFor(completions.length) — those stay as-is (out of scope). spent, balance = math.max(0, earned - spent), and every CompanionView field are otherwise unchanged.

  • Step 3: Runcd packages/client_sdk && fvm dart test test/companion_service_test.dart → PASS, then full suite green. Confirm the walling test (dewdrops never influenced by token ledger_entries) still passes untouched.

  • Step 4: Commit

git add packages/client_sdk/lib/src/services/companion_service.dart packages/client_sdk/test/companion_service_test.dart
git commit -m "feat(sp-c): CompanionService derives earned from the durable companion_earn ledger"

Post-plan

  • Run graphify update . and commit (chore: graphify update after SP-C).
  • Whole-branch SP-C review (opus) over the SP-C commit range.
  • DEPLOY (owner pre-authorized): apply 20260723000200_companion_durable_dewdrops.sql to prod bgedvvmihygwxhjxlvfu → advisor check (confirm companion_earn self-scoped RLS, the credit trigger fn revoked from public/anon/authenticated) → push → redeploy both URLs (app.rewhaven.com :8083, /rewhaven/ :8080) → rebuild the Android release APK for sideload. The migration is a hard prereq for the web deploy (the cloud zero-floor + getCompanionEarnEntries read companion_earn).

Self-Review notes (author)

  • Spec coverage: companion_earn ledger → Task 1+2; idempotent completion trigger (cloud) + adapter earn (local) → Task 1 (trigger) + Task 2 (adapters); zero-floor + service switch → Task 1 (SQL) + Task 3 (Dart); backfill → Task 1; adapter modeling → Task 2; tests → Task 2+3. Invariants (walling/append-only/zero-floor) preserved in both layers.
  • Deviations/notes: growth-stage/lifetimeCompletions durability is explicitly OUT of scope (owner scoped to "the points") — flagged as a related follow-up, not built. member_companion household FK unchanged (full survive-household-deletion needs the deferred identity work; SP-C makes the currency durable-and-portable only).
  • Type consistency: CompanionEarnEntry (Task 2) used identically in adapters + service; getCompanionEarnEntries(householdId, {memberId}) signature stable across StoragePort + all adapters + service; sourceCompletionId idempotency key consistent (SQL source_completion_id ↔ Dart sourceCompletionId).