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 BOTHCompanionServiceand 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_idis a plain uuid idempotency key, NOT a cascading FK tochore_completions— so a completion's later deletion/rollover cannot cascade-delete the earn. - Typed errors ONLY:
on <SpecificException>— never barecatch (e), never catchError.InsufficientDewdropsException(zero-floor) is unchanged. - Migration file-only under
infra/supabase/migrations/; prod apply tobgedvvmihygwxhjxlvfuis DEPLOY-GATED (owner pre-authorized for SP‑C). All SDK tests use the in-memory/fake adapters. - No app change:
CompanionView.dewdropsBalance/Earned/Spentfield shapes are unchanged; only the derivation insideCompanionServicechanges. Do NOT touchapp/. - Scope: dewdrops only.
lifetimeCompletions/growth-stage stay derived fromgetCompletions(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)
CompanionServicepackages/client_sdk/lib/src/services/companion_service.dart:earned = completions.length * kDewdropsPerCompletionat lines 139 & 241 (identical inpurchaseCosmeticand_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). ReturnsCompanionView{dewdropsEarned, dewdropsSpent, dewdropsBalance, lifetimeCompletions, stage, ownedCosmeticIds, messiness}(lines 254-266).kDewdropsPerCompletion = 1—packages/client_sdk/lib/src/models/companion.dart:8.StoragePortpackages/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(...))atapproval_service.dart:447-455. - Adapters (spend ledger + completions today):
- in-memory
memory/in_memory_storage_adapter.dart:_completionsmap (49, insert 346, get 336);_companionLedgermap (66, insert 695, get 683). - FakePort
test/support/fake_port.dart: publiccompletionsmap (17, insert 291);companionLedgerEntriesmap (35, insert 620). - local Drift
local/local_storage_adapter.dart: completions insert 421 / get 404 / mapper_completionFromRow1289; companion ledger insert 1062 / get 1035. Drift tables inlocal/local_database.dart:ChoreCompletions(351),MemberCompanions(729),CompanionLedgerRows(757,UNIQUE(memberId, cosmeticId)). - cached
cached/cached_storage_adapter.dart:insertCompletionwrite-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: mixinCompanionStore;companion_ledgerviadb.selectEq/db.insert; codec 59-95.
- in-memory
- 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-168—enforce_companion_zero_floor()BEFORE INSERT oncompanion_ledger;earned := count(chore_completions where member_id=new.member_id)(147-149);spent := sum(companion_ledger.dewdrops_cost)(151-153); per-memberpg_advisory_xact_lock(145).member_companion/companion_ledger/self_member_ids()RLS defined in this same migration. - Tests
test/companion_service_test.dart(seeds viaport.insertCompletion, helperseedCompletionslines 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 factorypackages/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 INSERTtrigger onchore_completionsappends onecompanion_earnrow (dewdrops_amount = 1,on conflict do nothing). -
enforce_companion_zero_floor()readsearned := 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(addCompanionEarnEntry) - 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(+ regenlocal_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(exportCompanionEarnEntry) - Test:
packages/client_sdk/test/companion_earn_adapter_test.dart
Interfaces:
- Consumes: Task 1's
companion_earncontract. - 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
insertCompletionALSO appends one idempotentcompanion_earnrow (keyed on(memberId, sourceCompletionId=completion.id)); the CLOUD adapter'sinsertCompletionis 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
CompanionEarnEntrymodel
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) — addgetCompanionEarnEntriesreadingcompanion_earnviadb.selectEq('companion_earn', {'household_id': householdId, if (memberId != null) 'member_id': memberId})mapped through a snake_case codec (mirror thecompanion_ledgercodec at 59-95:id,member_id,household_id,source_completion_id,dewdrops_amount,created_at). The cloudinsertCompletionis UNCHANGED — the DB trigger credits the earn. Addimportfor the model. -
Step 6: Local Drift adapter + codegen — in
local/local_database.dartadd aCompanionEarnRowstable (@DataClassName('CompanionEarnRow')):id TEXT pk,householdId TEXT(FK → Households ON DELETE CASCADE),memberId TEXT,sourceCompletionId TEXT,dewdropsAmount INT default 1,createdAt DateTime nullable, with aUNIQUE(memberId, sourceCompletionId)index; bump the DriftschemaVersion+ add theonUpgradestep creating the table. Inlocal_storage_adapter.dart:insertCompletion(421) ALSO inserts aCompanionEarnRowsCompanion.insert(...)idempotently (onConflict: DoNothing()on the unique index) keyed on the completion id; addgetCompanionEarnEntries(query_db.companionEarnRowsfiltered) + a_companionEarnFromRowmapper. Regenerate Drift with the scoped build filter to avoid clobbering hand-maintained.g.dartfiles:
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) —insertCompletionis already write-through (_durable.insertCompletionthen_cache.insertCompletion, 588); because BOTH the durable (local-Drift or cloud) and the cache (in-memory) now credit earn in their owninsertCompletion, no cached change is needed for crediting. AddgetCompanionEarnEntriesdelegating to_cache(served from cache like the ledger), and loadcompanion_earnin the hydration path besidecompanionLedgerEntries(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
CompanionEarnEntryfromclient_sdk.dart. Runcd 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:
earnedinCompanionViewderived fromsum(companion_earn)(notcompletions.length);lifetimeCompletions/stageunchanged (still fromgetCompletions). -
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
completionsmap without clearing earn (mirrors prod: completion deleted, earn preserved). AssertgetCompanion(...).dewdropsBalance == 2still (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 existingseedCompletionshelper (it callsport.insertCompletion, which now also credits earn). For the durability test, after seeding, doport.completions.clear()(FakePort exposes it) and assert the earn-derived balance holds.
- Durability: seed 5 completions (→ earned 5), buy a 3-cost cosmetic (balance 2). Then clear the port's
Run → FAIL (service still uses completions.length).
- Step 2: Switch
earnedinCompanionService— 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: Run —
cd packages/client_sdk && fvm dart test test/companion_service_test.dart→ PASS, then full suite green. Confirm the walling test (dewdrops never influenced by tokenledger_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.sqlto prodbgedvvmihygwxhjxlvfu→ advisor check (confirmcompanion_earnself-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 +getCompanionEarnEntriesreadcompanion_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/
lifetimeCompletionsdurability is explicitly OUT of scope (owner scoped to "the points") — flagged as a related follow-up, not built.member_companionhousehold 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;sourceCompletionIdidempotency key consistent (SQLsource_completion_id↔ DartsourceCompletionId).