Skip to main content

Companion Creature (Tier 1) Implementation Plan

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

Goal: Ship the per-child companion creature (Tier 1): a forgiving, ambient horizon-scene creature that reacts to chore completions, grows from lifetime completions, and spends its own never-convertible "dewdrops" currency on a small cosmetics catalog.

Architecture: One data path exactly like every other feature: CompanionBloc → CompanionRepository → Client facade → CompanionService → Adapter (Drift local + cloud PostgREST, cache-first via CachedStorageAdapter). Dewdrops earned are a projection over ChoreCompletion rows; spends live in a new physically-separate append-only companion_ledger; equipped cosmetics + name are the only genuinely mutable state (member_companion). The DS renders the creature/room layers over DsHorizonHouse and a focused DsCompanionSheet.

Tech Stack: Dart/Flutter (FVM), Drift (SQLite), Supabase PostgREST + RLS, flutter_bloc, mocktail, flow_test, golden_toolkit.

Spec (source of truth): docs/superpowers/specs/2026-07-18-companion-creature-design.md

Global Constraints

  • Dart/Flutter monorepo, FVM only: fvm flutter / fvm dart — never bare flutter/dart, never node for Dart tooling.
  • ONE data path: Bloc → Repository → Client facade → Service → Adapter; domain rules live in the SDK Service.
  • Migrations are FILE-ONLY in infra/supabase/migrations/ — the implementer writes the .sql file; the controller applies it live after review. Never call a Supabase apply tool.
  • Anon/publishable key only in app code; service-role only inside Edge Functions.
  • DUAL GATE: service authz AND RLS for every privileged step.
  • Dewdrops are earned from ChoreCompletion rows via Client.getCompletions — NEVER from the token ledger (expectations pay zero tokens and write no ledger entry).
  • Dewdrops are NEVER convertible to tokens; companion_ledger is physically separate from ledger_entries.
  • Growth never regresses; balance zero-floored at the fold.
  • Reduced motion honored via MotionTokens.durationOrZero + MediaQuery.disableAnimations.
  • Explicit git add <files> — never -A; never stage graphify-out/, .superpowers/, .claude/.
  • Suite baselines: app 553 / SDK 1049 tests must not drop; new tests add on top.
  • After code changes, run graphify update . (final task).
  • Codegen: Drift regen must use --build-filter to avoid clobbering hand-maintained .g.dart files (state.g.dart / router.gr.dart); if clobbered, restore from HEAD. The house command shape: fvm dart run build_runner build --build-filter "lib/src/adapters/local/local_database.g.dart" run from packages/client_sdk/ (verified: the Drift mirror lives in the SDK at packages/client_sdk/lib/src/adapters/local/local_database.g.dart, NOT in app/).

Task 1: Migration — member_companion + companion_ledger (file-only)

Files:

  • Create: infra/supabase/migrations/20260718000100_companion_creature.sql

Interfaces:

  • Consumes: existing RLS helpers public.member_household_ids() / public.parental_household_ids() (from 20260626000003_authz_rls_helpers.sql), public.chore_completions and the ledger_entries append-only house style (from 20260612000002_tier0_domain.sql).

  • Produces: tables public.member_companion (PK member_id) and public.companion_ledger (append-only, unique (member_id, cosmetic_id)), helper public.self_member_ids(), trigger companion_ledger_zero_floor. Later tasks (5, 6, 11) mirror these names exactly.

  • Step 1: Write the migration file

-- Companion Creature (Tier 1) — the per-child virtual companion's two tables
-- (design spec docs/superpowers/specs/2026-07-18-companion-creature-design.md).
--
-- * member_companion — ONE row per kid: species (fixed 'sprout' in v1), the
-- kid-set name, and the equipped cosmetic ids. The only genuinely mutable
-- companion state.
-- * companion_ledger — APPEND-ONLY spend ledger, PHYSICALLY SEPARATE from
-- ledger_entries (token-fade ADR: the two economies must never cannibalize;
-- dewdrops are never convertible to tokens). Owned cosmetics + the dewdrop
-- balance are DERIVED from this ledger + chore_completions — never stored.
--
-- Dual gate: these policies are the RLS half; CompanionService enforces the
-- same actor rules (child self-only mutation, parental household read) in Dart.

-- ── Helper: the calling account's OWN member rows ───────────────────────────
-- Mirrors the exact form of member_household_ids()/parental_household_ids()
-- (language sql stable security definer, pinned search_path, keyed on
-- auth.uid()). Self-scoped: powers the "a child touches ONLY its own
-- companion" policies below without recursing into household_members RLS.
create or replace function public.self_member_ids() returns setof uuid
language sql stable security definer set search_path to 'public' as $$
select id from public.household_members
where auth_user_id = auth.uid();
$$;

-- ── member_companion ────────────────────────────────────────────────────────
create table public.member_companion (
member_id uuid primary key
references public.household_members (id) on delete cascade,
household_id uuid not null
references public.households (id) on delete cascade,
species text not null default 'sprout' check (species = 'sprout'),
name text check (name is null or length(name) between 1 and 20),
equipped text[] not null default '{}',
created_at timestamptz not null default now()
);
create index member_companion_household_id_idx
on public.member_companion (household_id);

alter table public.member_companion enable row level security;

-- Read: the kid's OWN row, or any PARENTAL member of the same household
-- (parents may look; the kid OWNS it — no parent write in v1).
create policy member_companion_select on public.member_companion
for select to authenticated
using (
member_id in (select public.self_member_ids())
or household_id in (select public.parental_household_ids())
);

-- Create/update: the kid's OWN row ONLY (create-on-first-read + equip/rename).
create policy member_companion_insert on public.member_companion
for insert to authenticated
with check (member_id in (select public.self_member_ids()));
create policy member_companion_update on public.member_companion
for update to authenticated
using (member_id in (select public.self_member_ids()))
with check (member_id in (select public.self_member_ids()));
-- No delete policy: a companion is never destroyed (forgiving presence).

-- ── companion_ledger (APPEND-ONLY: insert + select policies ONLY) ───────────
-- The absence of update/delete policies is deliberate — one row per cosmetic
-- purchase is an immutable fact, exactly like ledger_entries.
create table public.companion_ledger (
id uuid primary key default gen_random_uuid(),
household_id uuid not null
references public.households (id) on delete cascade,
member_id uuid not null
references public.household_members (id) on delete cascade,
cosmetic_id text not null,
dewdrops_cost int not null check (dewdrops_cost > 0),
created_at timestamptz not null default now(),
-- Idempotent purchases: a cosmetic can be bought once (alreadyOwned's twin).
unique (member_id, cosmetic_id)
);
create index companion_ledger_household_id_idx
on public.companion_ledger (household_id);
create index companion_ledger_member_id_idx
on public.companion_ledger (member_id);

alter table public.companion_ledger enable row level security;

create policy companion_ledger_select on public.companion_ledger
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_ledger_insert on public.companion_ledger
for insert to authenticated
with check (member_id in (select public.self_member_ids()));

-- ── Dewdrop zero floor ──────────────────────────────────────────────────────
-- The SQL twin of CompanionService's balance check (mirrors enforce_zero_floor
-- on ledger_entries). Dewdrops EARNED are derived from chore_completions
-- (1 per completion — kDewdropsPerCompletion's twin). Sourcing from
-- completions, NEVER ledger_entries, is the A3/A4 guard: an expectation-only
-- kid (zero token rows) still earns.
create or replace function public.enforce_companion_zero_floor()
returns trigger
language plpgsql
as $$
declare
earned int;
spent int;
begin
select count(*) into earned
from public.chore_completions
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;
$$;

create trigger companion_ledger_zero_floor
before insert on public.companion_ledger
for each row execute function public.enforce_companion_zero_floor();
  • Step 2: Verify house invariants (file-level checks — the controller applies it live after review)

Run: grep -c "for update\|for delete" infra/supabase/migrations/20260718000100_companion_creature.sql Expected: 1 (exactly one for update — on member_companion; ZERO update/delete policies on companion_ledger).

Run: grep -n "security definer set search_path" infra/supabase/migrations/20260718000100_companion_creature.sql Expected: 1 hit — the self_member_ids() helper pins search_path like every house helper.

Run: grep -n "unique (member_id, cosmetic_id)" infra/supabase/migrations/20260718000100_companion_creature.sql Expected: 1 hit (the idempotent-purchase twin).

  • Step 3: Commit
git add infra/supabase/migrations/20260718000100_companion_creature.sql
git commit -m "feat: companion creature schema — member_companion + append-only companion_ledger (file-only)"

Task 2: SDK models, static cosmetics catalog, typed exceptions

Files:

  • Create: packages/client_sdk/lib/src/models/companion.dart
  • Modify: packages/client_sdk/lib/src/models/exceptions.dart (append four exception classes)
  • Modify: packages/client_sdk/lib/client_sdk.dart (barrel exports)
  • Test: packages/client_sdk/test/companion_model_test.dart

Interfaces:

  • Consumes: package:equatable/equatable.dart (house model style — hand-written, no json_serializable, like consent_record.dart: the cloud codec and Drift rows are hand-mapped, so no .g.dart is needed).

  • Produces (used verbatim by Tasks 3–10):

    • class Companion { String memberId; String householdId; String species; String? name; List<String> equipped; DateTime? createdAt; Companion copyWith({String? name, List<String>? equipped}) }
    • class CompanionLedgerEntry { String id; String householdId; String memberId; String cosmeticId; int dewdropsCost; DateTime? createdAt }
    • class CompanionView { Companion companion; int lifetimeCompletions; int dewdropsEarned; int dewdropsSpent; int dewdropsBalance; CompanionGrowthStage stage; Set<String> ownedCosmeticIds; CompanionMessinessTier messiness }
    • class CompanionCosmetic { String id; String displayName; CompanionCosmeticSlot slot; int dewdropsCost }, enum CompanionCosmeticSlot { hat, color }, const List<CompanionCosmetic> kCompanionCosmetics (6 items), CompanionCosmetic? companionCosmeticById(String id)
    • enum CompanionGrowthStage { seedling, sprout, bloom }, enum CompanionMessinessIntensity { full, subtle, off }, enum CompanionMessinessTier { tidy, leaves, cozyDusty }
    • consts: kCompanionSpecies = 'sprout', kDewdropsPerCompletion = 1, kGrowthStage2Completions = 10, kGrowthStage3Completions = 30, kCompanionNameMaxLength = 20, kMessinessLeavesDays = 7, kMessinessCozyDustyDays = 30
    • exceptions: InsufficientDewdropsException, CosmeticAlreadyOwnedException, UnknownCosmeticException, CosmeticNotOwnedException (all implements Exception with a message field, mirroring InsufficientBalanceException)
  • Step 1: Write the failing test

packages/client_sdk/test/companion_model_test.dart:

import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('cosmetics catalog', () {
test('is small, fixed, and well-formed: unique ids, positive prices, '
'both slots represented', () {
final ids = kCompanionCosmetics.map((c) => c.id).toSet();
expect(ids.length, kCompanionCosmetics.length,
reason: 'cosmetic ids must be unique');
expect(kCompanionCosmetics.length, 6);
expect(kCompanionCosmetics.every((c) => c.dewdropsCost > 0), isTrue);
expect(
kCompanionCosmetics.map((c) => c.slot).toSet(),
{CompanionCosmeticSlot.hat, CompanionCosmeticSlot.color},
);
});

test('companionCosmeticById resolves known ids and returns null for '
'unknown ones', () {
expect(companionCosmeticById('hat_sprout_cap')!.displayName,
'Sprout Cap');
expect(companionCosmeticById('nope'), isNull);
});
});

group('Companion model', () {
test('copyWith replaces name and equipped, preserves identity fields', () {
const original = Companion(
memberId: 'c1',
householdId: 'h1',
species: kCompanionSpecies,
);
final renamed = original.copyWith(
name: 'Fern',
equipped: const ['hat_sprout_cap'],
);
expect(renamed.memberId, 'c1');
expect(renamed.householdId, 'h1');
expect(renamed.name, 'Fern');
expect(renamed.equipped, const ['hat_sprout_cap']);
expect(original.name, isNull, reason: 'immutable: original untouched');
});
});

group('typed exceptions', () {
test('carry their message in toString (dialog mapping reads it)', () {
expect(const InsufficientDewdropsException('short').toString(),
'InsufficientDewdropsException: short');
expect(const CosmeticAlreadyOwnedException('owned').toString(),
'CosmeticAlreadyOwnedException: owned');
expect(const UnknownCosmeticException('what').toString(),
'UnknownCosmeticException: what');
expect(const CosmeticNotOwnedException('not yet').toString(),
'CosmeticNotOwnedException: not yet');
});
});

test('economy constants are the spec defaults', () {
expect(kDewdropsPerCompletion, 1);
expect(kGrowthStage2Completions, 10);
expect(kGrowthStage3Completions, 30);
expect(kCompanionNameMaxLength, 20);
expect(kMessinessLeavesDays, 7);
expect(kMessinessCozyDustyDays, 30);
});
}
  • Step 2: Run test to verify it fails

Run (from packages/client_sdk/): fvm flutter test test/companion_model_test.dart Expected: FAIL — compile error: Companion, kCompanionCosmetics, etc. are undefined.

  • Step 3: Write minimal implementation

packages/client_sdk/lib/src/models/companion.dart (new file, complete):

import 'package:equatable/equatable.dart';

/// v1 ships a single species; the SQL column is CHECK-pinned to this value.
const String kCompanionSpecies = 'sprout';

/// Dewdrops granted per approved ChoreCompletion (earn rule, spec §5).
/// Earned = completions × this rate; there is no explicit "feed" action.
const int kDewdropsPerCompletion = 1;

/// Growth-stage thresholds in LIFETIME completions (behavior-gated: growth is
/// earned by doing, never bought). Stage 1 (seedling) from the start.
const int kGrowthStage2Completions = 10;
const int kGrowthStage3Completions = 30;

/// Companion name length cap (rename validation; SQL CHECK twin: 1..20).
const int kCompanionNameMaxLength = 20;

/// Cozy-messiness tier boundaries in days since the last completion. The tier
/// enum itself is the HARD CAP: >= [kMessinessCozyDustyDays] never worsens
/// (two months looks no worse than one).
const int kMessinessLeavesDays = 7;
const int kMessinessCozyDustyDays = 30;

/// Behavior-gated growth stages (spec §5). Growth only advances — the input
/// (lifetime completions) is append-only, so regression is impossible.
enum CompanionGrowthStage { seedling, sprout, bloom }

/// The cozy-messy room intensity setting: `full` shows all tiers, `subtle`
/// caps at [CompanionMessinessTier.leaves], `off` is always tidy.
enum CompanionMessinessIntensity { full, subtle, off }

/// The gentle, capped neglect signal — on the ROOM, never the creature.
enum CompanionMessinessTier { tidy, leaves, cozyDusty }

/// The two cosmetic slots of the v1 catalog. One equipped item per slot.
enum CompanionCosmeticSlot { hat, color }

/// One entry of the STATIC v1 cosmetics catalog (like the rewards catalog
/// shape: fixed set, dewdrop prices, no per-household config).
class CompanionCosmetic extends Equatable {
const CompanionCosmetic({
required this.id,
required this.displayName,
required this.slot,
required this.dewdropsCost,
});

final String id;
final String displayName;
final CompanionCosmeticSlot slot;
final int dewdropsCost;

@override
List<Object?> get props => [id, displayName, slot, dewdropsCost];
}

/// The fixed v1 catalog: 6 items across the 2 slots.
const List<CompanionCosmetic> kCompanionCosmetics = <CompanionCosmetic>[
CompanionCosmetic(
id: 'hat_sprout_cap',
displayName: 'Sprout Cap',
slot: CompanionCosmeticSlot.hat,
dewdropsCost: 5,
),
CompanionCosmetic(
id: 'hat_acorn',
displayName: 'Acorn Beret',
slot: CompanionCosmeticSlot.hat,
dewdropsCost: 8,
),
CompanionCosmetic(
id: 'hat_flower',
displayName: 'Flower Crown',
slot: CompanionCosmeticSlot.hat,
dewdropsCost: 12,
),
CompanionCosmetic(
id: 'color_moss',
displayName: 'Moss Green',
slot: CompanionCosmeticSlot.color,
dewdropsCost: 5,
),
CompanionCosmetic(
id: 'color_dusk',
displayName: 'Dusk Blue',
slot: CompanionCosmeticSlot.color,
dewdropsCost: 8,
),
CompanionCosmetic(
id: 'color_ember',
displayName: 'Ember Gold',
slot: CompanionCosmeticSlot.color,
dewdropsCost: 12,
),
];

/// Catalog lookup; null for an unknown id (mapped to UnknownCosmeticException
/// by CompanionService).
CompanionCosmetic? companionCosmeticById(String id) {
for (final cosmetic in kCompanionCosmetics) {
if (cosmetic.id == id) return cosmetic;
}
return null;
}

/// The one-row-per-kid mutable companion state (member_companion twin).
class Companion extends Equatable {
const Companion({
required this.memberId,
required this.householdId,
required this.species,
this.name,
this.equipped = const <String>[],
this.createdAt,
});

final String memberId;
final String householdId;
final String species;

/// Kid-set display name; null until first rename.
final String? name;

/// Equipped cosmetic ids (at most one per [CompanionCosmeticSlot]).
final List<String> equipped;
final DateTime? createdAt;

Companion copyWith({String? name, List<String>? equipped}) {
return Companion(
memberId: memberId,
householdId: householdId,
species: species,
name: name ?? this.name,
equipped: equipped ?? this.equipped,
createdAt: createdAt,
);
}

@override
List<Object?> get props =>
[memberId, householdId, species, name, equipped, createdAt];
}

/// One APPEND-ONLY cosmetic purchase (companion_ledger twin). No copyWith:
/// a purchase is an immutable fact, exactly like LedgerEntry.
class CompanionLedgerEntry extends Equatable {
const CompanionLedgerEntry({
required this.id,
required this.householdId,
required this.memberId,
required this.cosmeticId,
required this.dewdropsCost,
this.createdAt,
});

final String id;
final String householdId;
final String memberId;
final String cosmeticId;
final int dewdropsCost;
final DateTime? createdAt;

@override
List<Object?> get props =>
[id, householdId, memberId, cosmeticId, dewdropsCost, createdAt];
}

/// The single aggregate the app reads: companion row + every derived value
/// (balance, stage, owned set, messiness) so the bloc makes ONE facade call.
class CompanionView extends Equatable {
const CompanionView({
required this.companion,
required this.lifetimeCompletions,
required this.dewdropsEarned,
required this.dewdropsSpent,
required this.dewdropsBalance,
required this.stage,
required this.ownedCosmeticIds,
required this.messiness,
});

final Companion companion;
final int lifetimeCompletions;
final int dewdropsEarned;
final int dewdropsSpent;

/// earned − spent, zero-floored at the fold.
final int dewdropsBalance;
final CompanionGrowthStage stage;
final Set<String> ownedCosmeticIds;
final CompanionMessinessTier messiness;

@override
List<Object?> get props => [
companion,
lifetimeCompletions,
dewdropsEarned,
dewdropsSpent,
dewdropsBalance,
stage,
ownedCosmeticIds,
messiness,
];
}

Append to packages/client_sdk/lib/src/models/exceptions.dart (after the InsufficientBalanceException class, mirroring its exact shape):

/// Companion economy: dewdrop balance below the cosmetic's cost (spec §5).
class InsufficientDewdropsException implements Exception {
const InsufficientDewdropsException(this.message);
final String message;

@override
String toString() => 'InsufficientDewdropsException: $message';
}

/// Companion economy: the cosmetic is already in the ledger — the purchase is
/// idempotent and must never double-charge (spec §5).
class CosmeticAlreadyOwnedException implements Exception {
const CosmeticAlreadyOwnedException(this.message);
final String message;

@override
String toString() => 'CosmeticAlreadyOwnedException: $message';
}

/// Companion economy: the cosmetic id is not in the static catalog.
class UnknownCosmeticException implements Exception {
const UnknownCosmeticException(this.message);
final String message;

@override
String toString() => 'UnknownCosmeticException: $message';
}

/// Companion equip: only an OWNED cosmetic can be equipped.
class CosmeticNotOwnedException implements Exception {
const CosmeticNotOwnedException(this.message);
final String message;

@override
String toString() => 'CosmeticNotOwnedException: $message';
}

Modify packages/client_sdk/lib/client_sdk.dart: add a model export next to the other src/models/ exports, and extend the exceptions show list:

export 'src/models/companion.dart'
show
Companion,
CompanionCosmetic,
CompanionCosmeticSlot,
CompanionGrowthStage,
CompanionLedgerEntry,
CompanionMessinessIntensity,
CompanionMessinessTier,
CompanionView,
companionCosmeticById,
kCompanionCosmetics,
kCompanionNameMaxLength,
kCompanionSpecies,
kDewdropsPerCompletion,
kGrowthStage2Completions,
kGrowthStage3Completions,
kMessinessCozyDustyDays,
kMessinessLeavesDays;

and in the existing export 'src/models/exceptions.dart' show ...; list add:

CosmeticAlreadyOwnedException,
CosmeticNotOwnedException,
InsufficientDewdropsException,
UnknownCosmeticException,
  • Step 4: Run test to verify it passes

Run (from packages/client_sdk/): fvm flutter test test/companion_model_test.dart Expected: PASS (all groups green).

  • Step 5: Commit
git add packages/client_sdk/lib/src/models/companion.dart packages/client_sdk/lib/src/models/exceptions.dart packages/client_sdk/lib/client_sdk.dart packages/client_sdk/test/companion_model_test.dart
git commit -m "feat: companion SDK models, static cosmetics catalog, typed exceptions"

Task 3: Storage port + in-memory adapters (parity doubles)

Files:

  • Modify: packages/client_sdk/lib/src/adapters/adapter.dart (add 5 abstract methods to StoragePort)
  • Modify: packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart
  • Modify: packages/client_sdk/test/support/fake_port.dart
  • Test: packages/client_sdk/test/companion_port_test.dart

Interfaces:

  • Consumes: Companion, CompanionLedgerEntry from Task 2.

  • Produces (implemented by every adapter; consumed by CompanionService in Task 4):

    • Future<Companion?> getCompanion(String memberId)
    • Future<Companion> insertCompanion(Companion companion)
    • Future<Companion> updateCompanion(Companion companion)
    • Future<List<CompanionLedgerEntry>> getCompanionLedgerEntries(String householdId, {String? memberId})
    • Future<CompanionLedgerEntry> insertCompanionLedgerEntry(CompanionLedgerEntry entry)
  • Step 1: Write the failing test

packages/client_sdk/test/companion_port_test.dart:

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

void main() {
late InMemoryStorageAdapter adapter;

setUp(() {
adapter = InMemoryStorageAdapter();
});

group('member_companion port (in-memory)', () {
test('getCompanion is null before insert; round-trips after', () async {
expect(await adapter.getCompanion('c1'), isNull);
const companion = Companion(
memberId: 'c1',
householdId: 'h1',
species: kCompanionSpecies,
);
await adapter.insertCompanion(companion);
expect(await adapter.getCompanion('c1'), companion);
});

test('updateCompanion persists rename + equip', () async {
const companion = Companion(
memberId: 'c1',
householdId: 'h1',
species: kCompanionSpecies,
);
await adapter.insertCompanion(companion);
await adapter.updateCompanion(
companion.copyWith(name: 'Fern', equipped: const ['hat_sprout_cap']),
);
final read = await adapter.getCompanion('c1');
expect(read!.name, 'Fern');
expect(read.equipped, const ['hat_sprout_cap']);
});
});

group('companion_ledger port (in-memory, append-only)', () {
test('appends and filters by household + member', () async {
const e1 = CompanionLedgerEntry(
id: 'l1',
householdId: 'h1',
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
dewdropsCost: 5,
);
const e2 = CompanionLedgerEntry(
id: 'l2',
householdId: 'h1',
memberId: 'c2',
cosmeticId: 'color_moss',
dewdropsCost: 5,
);
await adapter.insertCompanionLedgerEntry(e1);
await adapter.insertCompanionLedgerEntry(e2);
expect(await adapter.getCompanionLedgerEntries('h1'), [e1, e2]);
expect(
await adapter.getCompanionLedgerEntries('h1', memberId: 'c1'),
[e1],
);
expect(await adapter.getCompanionLedgerEntries('other'), isEmpty);
});
});
}
  • Step 2: Run test to verify it fails

Run (from packages/client_sdk/): fvm flutter test test/companion_port_test.dart Expected: FAIL — compile error: getCompanion is not defined for InMemoryStorageAdapter.

  • Step 3: Write minimal implementation

Add to packages/client_sdk/lib/src/adapters/adapter.dart (inside abstract class StoragePort, after the ledger section; add import '../models/companion.dart'; beside the other model imports):

// ── Companion (member_companion + append-only companion_ledger) ──────────

/// The member's companion row, or null before create-on-first-read.
Future<Companion?> getCompanion(String memberId);

/// Inserts the ONE row per kid (create-on-first-read; PK member_id).
Future<Companion> insertCompanion(Companion companion);

/// Persists equip/rename — the only mutable companion state.
Future<Companion> updateCompanion(Companion companion);

/// Reads APPEND-ONLY cosmetic-purchase rows, optionally scoped to
/// [memberId]. Owned cosmetics + the dewdrop balance are DERIVED from these
/// rows — never stored.
Future<List<CompanionLedgerEntry>> getCompanionLedgerEntries(
String householdId, {
String? memberId,
});

/// Appends one APPEND-ONLY purchase row. No update/delete signature exists
/// in this port or any adapter — the companion_ledger twin of the
/// ledger_entries invariant.
Future<CompanionLedgerEntry> insertCompanionLedgerEntry(
CompanionLedgerEntry entry,
);

Add to packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart (new backing maps beside the existing ones, methods in a new section; add import '../../models/companion.dart';):

final Map<String, Companion> _companions = {};
final Map<String, CompanionLedgerEntry> _companionLedger = {};
// ── Companion (member_companion + append-only companion_ledger) ──

@override
Future<Companion?> getCompanion(String memberId) async =>
_companions[memberId];

@override
Future<Companion> insertCompanion(Companion companion) async {
_companions[companion.memberId] = companion;
return companion;
}

@override
Future<Companion> updateCompanion(Companion companion) async {
_companions[companion.memberId] = companion;
return companion;
}

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

@override
Future<CompanionLedgerEntry> insertCompanionLedgerEntry(
CompanionLedgerEntry entry,
) async {
_companionLedger[entry.id] = entry;
return entry;
}

Add the IDENTICAL five methods to packages/client_sdk/test/support/fake_port.dart inside class FakePort — with PUBLIC maps matching its style (final Map<String, ChoreCompletion> completions = {} etc.), so service tests can inspect them:

final Map<String, Companion> companions = {};
final Map<String, CompanionLedgerEntry> companionLedgerEntries = {};

// ── Companion ───────────────────────────────────────────────

@override
Future<Companion?> getCompanion(String memberId) async =>
companions[memberId];

@override
Future<Companion> insertCompanion(Companion companion) async {
companions[companion.memberId] = companion;
return companion;
}

@override
Future<Companion> updateCompanion(Companion companion) async {
companions[companion.memberId] = companion;
return companion;
}

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

@override
Future<CompanionLedgerEntry> insertCompanionLedgerEntry(
CompanionLedgerEntry entry,
) async {
companionLedgerEntries[entry.id] = entry;
return entry;
}

NOTE: adding abstract methods to StoragePort breaks EVERY implementer until Tasks 5–6 land. To keep the suite green within this task, also add the same five methods now to CachedStorageAdapter (delegation — full code in Task 6 Step 3, apply it here), LocalStorageAdapter and SupabaseStorageAdapter (full code in Task 5/6 Step 3 — apply the adapter method bodies now; the Drift TABLE + codegen and the hydration/round-trip TESTS stay in Tasks 5–6). If preferred, Tasks 3–6 may be committed together; the commit split below assumes the compile-carrying bodies land here.

  • Step 4: Run test to verify it passes

Run (from packages/client_sdk/): fvm flutter test test/companion_port_test.dart Expected: PASS. Then run the FULL SDK suite to confirm no implementer is left abstract: fvm flutter test Expected: PASS, ≥ 1049 tests.

  • Step 5: Commit
git add packages/client_sdk/lib/src/adapters/adapter.dart packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart packages/client_sdk/test/support/fake_port.dart packages/client_sdk/test/companion_port_test.dart
git commit -m "feat: companion storage port + in-memory adapter parity"

Task 4: CompanionService — domain rules + full unit-test suite

Files:

  • Create: packages/client_sdk/lib/src/services/companion_service.dart
  • Test: packages/client_sdk/test/companion_service_test.dart

Interfaces:

  • Consumes: StoragePort (Task 3 methods + existing getCompletions(householdId, {memberId}), getMembers(householdId), getHousehold()), models/exceptions (Task 2), IdGenerator (String next()), HouseholdMember.kind.isParental.
  • Produces (consumed verbatim by the facade in Task 7):
    • CompanionService({required StoragePort storage, IdGenerator? idGenerator, DateTime Function()? now, Future<HouseholdMember?> Function()? currentMember})
    • Future<CompanionView> getCompanion(String memberId, {CompanionMessinessIntensity intensity = CompanionMessinessIntensity.full})
    • Future<CompanionView> purchaseCosmetic({required String memberId, required String cosmeticId})
    • Future<CompanionView> equipCosmetic({required String memberId, required String cosmeticId})
    • Future<CompanionView> renameCompanion({required String memberId, required String name})
    • static CompanionGrowthStage stageFor(int lifetimeCompletions)
    • static CompanionMessinessTier cozyMessiness({required int daysSinceLastCompletion, required CompanionMessinessIntensity intensity})

Design decision (documented): getCompanion is CREATE-ON-FIRST-READ — a SELF read persists the default row; a parent reading a kid whose row does not exist yet gets an ephemeral default view and writes NOTHING (no parent write in v1; the self-only RLS insert policy also physically blocks it). Actor rules mirror EconomyService's currentMember callback: null callback/actor (local tier) no-ops and RLS is the backstop.

  • Step 1: Write the failing test

packages/client_sdk/test/companion_service_test.dart:

import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk/src/services/companion_service.dart';
import 'package:flutter_test/flutter_test.dart';

import 'support/fake_port.dart';

void main() {
late FakePort port;
late CompanionService service;
late HouseholdMember parent;
late HouseholdMember child;
late HouseholdMember sibling;

final clock = DateTime(2026, 7, 18);

CompanionService serviceAs(HouseholdMember? actor) => CompanionService(
storage: port,
now: () => clock,
currentMember: actor == null ? null : () async => actor,
);

Future<void> seedCompletions(
int count, {
String memberId = 'c1',
DateTime? at,
}) async {
final start = port.completions.length;
for (var i = 0; i < count; i++) {
await port.insertCompletion(
ChoreCompletion(
id: 'comp-${start + i}',
householdId: 'h1',
choreId: 'chore-1',
memberId: memberId,
completedAt: at ?? DateTime(2026, 7, 17),
),
);
}
}

setUp(() async {
port = FakePort();
await port.insertHousehold(const Household(id: 'h1', name: 'Casa'));
parent = const HouseholdMember(
id: 'p1',
householdId: 'h1',
displayName: 'Pat',
kind: MemberKind.parent,
);
await port.insertMember(parent);
child = const HouseholdMember(
id: 'c1',
householdId: 'h1',
displayName: 'Sam',
kind: MemberKind.child,
age: 8,
consentState: ConsentState.granted,
);
await port.insertMember(child);
sibling = const HouseholdMember(
id: 'c2',
householdId: 'h1',
displayName: 'Ash',
kind: MemberKind.child,
age: 10,
consentState: ConsentState.granted,
);
await port.insertMember(sibling);
service = serviceAs(null); // local tier — no actor gate
});

group('earn projection (the A3/A4 guard)', () {
test('an EXPECTATION-ONLY kid still earns: dewdrops come from '
'ChoreCompletion rows, never the token ledger', () async {
// 4 completions, ZERO ledger entries (expectations pay no tokens).
await seedCompletions(4);
expect(port.ledgerEntries, isEmpty,
reason: 'arrange: no token-ledger rows exist at all');
final view = await service.getCompanion('c1');
expect(view.dewdropsEarned, 4 * kDewdropsPerCompletion);
expect(view.dewdropsBalance, 4);
expect(view.lifetimeCompletions, 4);
});

test('zero completions -> zero earned, seedling, tidy', () async {
final view = await service.getCompanion('c1');
expect(view.dewdropsEarned, 0);
expect(view.stage, CompanionGrowthStage.seedling);
expect(view.messiness, CompanionMessinessTier.tidy,
reason: 'a brand-new kid never starts in a messy room');
});
});

group('zero floor', () {
test('balance is zero-floored at the fold even if the stored ledger '
'over-spends (race/replay can never surface a negative)', () async {
await seedCompletions(2);
// Bypass the service (simulating a replayed cloud row): spend 5 of 2.
await port.insertCompanionLedgerEntry(
const CompanionLedgerEntry(
id: 'l1',
householdId: 'h1',
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
dewdropsCost: 5,
),
);
final view = await service.getCompanion('c1');
expect(view.dewdropsSpent, 5);
expect(view.dewdropsBalance, 0, reason: 'zero-floored, never -3');
});
});

group('purchase', () {
test('happy path: appends one ledger row, decrements balance, marks '
'owned', () async {
await seedCompletions(6);
final view = await service.purchaseCosmetic(
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
);
expect(port.companionLedgerEntries.values.single.dewdropsCost, 5);
expect(view.dewdropsBalance, 1);
expect(view.ownedCosmeticIds, {'hat_sprout_cap'});
});

test('insufficientDewdrops: balance < cost throws and appends '
'nothing', () async {
await seedCompletions(3); // Sprout Cap costs 5
await expectLater(
() => service.purchaseCosmetic(
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
),
throwsA(isA<InsufficientDewdropsException>()),
);
expect(port.companionLedgerEntries, isEmpty);
});

test('double purchase is idempotent: alreadyOwned, NO double-charge',
() async {
await seedCompletions(20);
await service.purchaseCosmetic(
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
);
await expectLater(
() => service.purchaseCosmetic(
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
),
throwsA(isA<CosmeticAlreadyOwnedException>()),
);
expect(port.companionLedgerEntries.length, 1,
reason: 'exactly one charge');
final view = await service.getCompanion('c1');
expect(view.dewdropsBalance, 15, reason: '20 - 5, charged once');
});

test('unknownCosmetic: id not in the static catalog', () async {
await seedCompletions(20);
await expectLater(
() => service.purchaseCosmetic(memberId: 'c1', cosmeticId: 'nope'),
throwsA(isA<UnknownCosmeticException>()),
);
});
});

group('equip', () {
test('notOwned: equipping an unowned cosmetic throws', () async {
await seedCompletions(20);
await expectLater(
() => service.equipCosmetic(
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
),
throwsA(isA<CosmeticNotOwnedException>()),
);
});

test('owned equips; same-slot equip REPLACES, cross-slot stacks',
() async {
await seedCompletions(30);
await service.purchaseCosmetic(
memberId: 'c1', cosmeticId: 'hat_sprout_cap');
await service.purchaseCosmetic(memberId: 'c1', cosmeticId: 'hat_acorn');
await service.purchaseCosmetic(
memberId: 'c1', cosmeticId: 'color_moss');
await service.equipCosmetic(
memberId: 'c1', cosmeticId: 'hat_sprout_cap');
await service.equipCosmetic(memberId: 'c1', cosmeticId: 'color_moss');
var view = await service.getCompanion('c1');
expect(view.companion.equipped, ['hat_sprout_cap', 'color_moss']);
// Same-slot replacement: the second hat evicts the first.
await service.equipCosmetic(memberId: 'c1', cosmeticId: 'hat_acorn');
view = await service.getCompanion('c1');
expect(view.companion.equipped, ['color_moss', 'hat_acorn']);
});
});

group('rename', () {
test('persists the trimmed name', () async {
final view = await service.renameCompanion(
memberId: 'c1',
name: ' Fern ',
);
expect(view.companion.name, 'Fern');
expect(port.companions['c1']!.name, 'Fern');
});

test('empty and over-cap names throw ValidationException', () async {
await expectLater(
() => service.renameCompanion(memberId: 'c1', name: ' '),
throwsA(isA<ValidationException>()),
);
await expectLater(
() => service.renameCompanion(
memberId: 'c1',
name: 'x' * (kCompanionNameMaxLength + 1),
),
throwsA(isA<ValidationException>()),
);
});
});

group('growth stages (behavior-gated, never regresses)', () {
test('exact thresholds: 0/9 seedling, 10/29 sprout, 30 bloom', () {
expect(CompanionService.stageFor(0), CompanionGrowthStage.seedling);
expect(CompanionService.stageFor(9), CompanionGrowthStage.seedling);
expect(CompanionService.stageFor(10), CompanionGrowthStage.sprout);
expect(CompanionService.stageFor(29), CompanionGrowthStage.sprout);
expect(CompanionService.stageFor(30), CompanionGrowthStage.bloom);
expect(CompanionService.stageFor(500), CompanionGrowthStage.bloom);
});

test('never regresses: stageFor is monotonic over the append-only '
'completions substrate (growth pauses, never reverses)', () {
var previous = CompanionService.stageFor(0);
for (var n = 1; n <= 60; n++) {
final next = CompanionService.stageFor(n);
expect(next.index, greaterThanOrEqualTo(previous.index),
reason: 'stage at $n completions must never be below $previous');
previous = next;
}
});

test('cosmetics cannot buy growth: stage is unchanged by spending',
() async {
await seedCompletions(10);
final before = await service.getCompanion('c1');
expect(before.stage, CompanionGrowthStage.sprout);
await service.purchaseCosmetic(
memberId: 'c1', cosmeticId: 'hat_sprout_cap');
final after = await service.getCompanion('c1');
expect(after.stage, CompanionGrowthStage.sprout);
});
});

group('cozy-messiness projection', () {
test('tier boundaries: <7 tidy, 7..29 leaves, >=30 cozyDusty', () {
const full = CompanionMessinessIntensity.full;
expect(
CompanionService.cozyMessiness(
daysSinceLastCompletion: 0, intensity: full),
CompanionMessinessTier.tidy,
);
expect(
CompanionService.cozyMessiness(
daysSinceLastCompletion: 6, intensity: full),
CompanionMessinessTier.tidy,
);
expect(
CompanionService.cozyMessiness(
daysSinceLastCompletion: 7, intensity: full),
CompanionMessinessTier.leaves,
);
expect(
CompanionService.cozyMessiness(
daysSinceLastCompletion: 29, intensity: full),
CompanionMessinessTier.leaves,
);
expect(
CompanionService.cozyMessiness(
daysSinceLastCompletion: 30, intensity: full),
CompanionMessinessTier.cozyDusty,
);
});

test('HARD CAP: two months looks no worse than one', () {
expect(
CompanionService.cozyMessiness(
daysSinceLastCompletion: 60,
intensity: CompanionMessinessIntensity.full,
),
CompanionService.cozyMessiness(
daysSinceLastCompletion: 30,
intensity: CompanionMessinessIntensity.full,
),
);
});

test('off -> always tidy; subtle caps at leaves', () {
expect(
CompanionService.cozyMessiness(
daysSinceLastCompletion: 400,
intensity: CompanionMessinessIntensity.off,
),
CompanionMessinessTier.tidy,
);
expect(
CompanionService.cozyMessiness(
daysSinceLastCompletion: 45,
intensity: CompanionMessinessIntensity.subtle,
),
CompanionMessinessTier.leaves,
);
});

test('a fresh completion steps the room back toward tidy', () async {
await seedCompletions(1, at: DateTime(2026, 6, 1)); // 47 days idle
var view = await service.getCompanion('c1');
expect(view.messiness, CompanionMessinessTier.cozyDusty);
await seedCompletions(1, at: DateTime(2026, 7, 17)); // yesterday
view = await service.getCompanion('c1');
expect(view.messiness, CompanionMessinessTier.tidy);
});
});

group('actor authz (service half of the dual gate)', () {
test('a child mutating a SIBLING companion fails with '
'AuthorizationFailure', () async {
await seedCompletions(20, memberId: 'c2');
final asChild = serviceAs(child);
await expectLater(
() => asChild.purchaseCosmetic(
memberId: 'c2',
cosmeticId: 'hat_sprout_cap',
),
throwsA(isA<AuthorizationFailure>()),
);
await expectLater(
() => asChild.renameCompanion(memberId: 'c2', name: 'Mine'),
throwsA(isA<AuthorizationFailure>()),
);
await expectLater(
() => asChild.getCompanion('c2'),
throwsA(isA<AuthorizationFailure>()),
reason: 'a child cannot even READ a sibling companion',
);
});

test('a parent may READ a kid companion but NOT write it (v1)', () async {
await seedCompletions(20);
final asChild = serviceAs(child);
await asChild.getCompanion('c1'); // self create-on-first-read
final asParent = serviceAs(parent);
final view = await asParent.getCompanion('c1');
expect(view.companion.memberId, 'c1');
await expectLater(
() => asParent.renameCompanion(memberId: 'c1', name: 'Dadname'),
throwsA(isA<AuthorizationFailure>()),
);
await expectLater(
() => asParent.purchaseCosmetic(
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
),
throwsA(isA<AuthorizationFailure>()),
);
});
});

group('create-on-first-read (design decision)', () {
test('a SELF read persists the default row', () async {
final asChild = serviceAs(child);
expect(port.companions, isEmpty);
final view = await asChild.getCompanion('c1');
expect(view.companion.species, kCompanionSpecies);
expect(port.companions['c1'], isNotNull, reason: 'row persisted');
});

test('a PARENT read of a missing row returns an ephemeral default and '
'writes NOTHING', () async {
final asParent = serviceAs(parent);
final view = await asParent.getCompanion('c1');
expect(view.companion.species, kCompanionSpecies);
expect(port.companions, isEmpty, reason: 'no parent write in v1');
});
});
}
  • Step 2: Run test to verify it fails

Run (from packages/client_sdk/): fvm flutter test test/companion_service_test.dart Expected: FAIL — compile error: package:client_sdk/src/services/companion_service.dart does not exist.

  • Step 3: Write minimal implementation

packages/client_sdk/lib/src/services/companion_service.dart (new file, complete):

import 'dart:math' as math;

import '../adapters/adapter.dart';
import '../models/companion.dart';
import '../models/exceptions.dart';
import '../models/household.dart';
import '../models/household_member.dart';
import 'id_generator.dart';

/// Domain rules for the per-child companion creature (Tier 1). ALL companion
/// logic lives here behind the facade — the app never computes economy or
/// growth itself (spec §5).
///
/// Invariants enforced here (the service half of the dual gate; RLS is the
/// schema half):
/// * dewdrops are earned from ChoreCompletion rows — NEVER the token ledger
/// (expectations pay zero tokens and write no ledger entry: the A3/A4
/// guard);
/// * dewdrops are never convertible to tokens (this service never touches
/// LedgerService or ledger_entries at all);
/// * balance is zero-floored at the fold; the companion_ledger is
/// append-only;
/// * growth is a pure function of lifetime completions and never regresses;
/// * a child mutates ONLY its own companion; parents read within the
/// household but never write (v1).
class CompanionService {
CompanionService({
required StoragePort storage,
IdGenerator? idGenerator,
DateTime Function()? now,
Future<HouseholdMember?> Function()? currentMember,
}) : _storage = storage,
_ids = idGenerator ?? const IdGenerator(),
_now = now ?? DateTime.now,
_currentMember = currentMember;

final StoragePort _storage;
final IdGenerator _ids;
final DateTime Function() _now;

/// Resolves the AUTHENTICATED account's member (the same seam
/// EconomyService uses). Null on the free/local tier — the actor gate then
/// no-ops and RLS is the sole backstop.
final Future<HouseholdMember?> Function()? _currentMember;

/// Growth is a PURE function of lifetime completions. The completions
/// substrate is append-only (no delete path exists on any adapter), so the
/// input is monotonic and the stage can only ever advance — the
/// forgiving-presence "never regresses" invariant enforced in code.
static CompanionGrowthStage stageFor(int lifetimeCompletions) {
if (lifetimeCompletions >= kGrowthStage3Completions) {
return CompanionGrowthStage.bloom;
}
if (lifetimeCompletions >= kGrowthStage2Completions) {
return CompanionGrowthStage.sprout;
}
return CompanionGrowthStage.seedling;
}

/// Cozy-messiness projection — capped, optional, on the ROOM never the
/// creature. `off` is always tidy; `subtle` caps at [CompanionMessinessTier
/// .leaves]; the tier enum itself is the hard cap, so two months never
/// looks worse than one. No failure modes.
static CompanionMessinessTier cozyMessiness({
required int daysSinceLastCompletion,
required CompanionMessinessIntensity intensity,
}) {
if (intensity == CompanionMessinessIntensity.off) {
return CompanionMessinessTier.tidy;
}
if (daysSinceLastCompletion < kMessinessLeavesDays) {
return CompanionMessinessTier.tidy;
}
if (daysSinceLastCompletion < kMessinessCozyDustyDays ||
intensity == CompanionMessinessIntensity.subtle) {
return CompanionMessinessTier.leaves;
}
return CompanionMessinessTier.cozyDusty;
}

/// Loads the member's companion + every derived value in ONE aggregate.
///
/// CREATE-ON-FIRST-READ (design decision): a SELF read persists the default
/// row; a parent reading a kid with no row yet gets an ephemeral default
/// view and writes NOTHING (no parent write in v1 — the self-only RLS
/// insert policy physically blocks it too).
Future<CompanionView> getCompanion(
String memberId, {
CompanionMessinessIntensity intensity = CompanionMessinessIntensity.full,
}) async {
final household = await _requireHousehold();
await _requireMember(household.id, memberId);
await _assertActorMayRead(household.id, memberId);
var companion = await _storage.getCompanion(memberId);
if (companion == null) {
companion = Companion(
memberId: memberId,
householdId: household.id,
species: kCompanionSpecies,
createdAt: _now(),
);
final actor = await _actor();
if (actor == null || actor.id == memberId) {
companion = await _storage.insertCompanion(companion);
}
}
return _viewOf(household, companion, intensity: intensity);
}

/// The ONE mutating economy op: validates, then appends one ledger row.
Future<CompanionView> purchaseCosmetic({
required String memberId,
required String cosmeticId,
}) async {
final household = await _requireHousehold();
await _requireMember(household.id, memberId);
await _assertActorSelf(memberId);
final cosmetic = companionCosmeticById(cosmeticId);
if (cosmetic == null) {
throw UnknownCosmeticException(
'No cosmetic with id $cosmeticId in the catalog.',
);
}
final companion = await _requireCompanion(household, memberId);
final ledger = await _storage.getCompanionLedgerEntries(
household.id,
memberId: memberId,
);
if (ledger.any((e) => e.cosmeticId == cosmeticId)) {
throw CosmeticAlreadyOwnedException(
'$cosmeticId is already owned — purchases are idempotent, '
'no double-charge.',
);
}
final completions = await _storage.getCompletions(
household.id,
memberId: memberId,
);
final earned = completions.length * kDewdropsPerCompletion;
final spent = ledger.fold<int>(0, (sum, e) => sum + e.dewdropsCost);
final balance = math.max(0, earned - spent);
if (balance < cosmetic.dewdropsCost) {
throw InsufficientDewdropsException(
'${cosmetic.displayName} costs ${cosmetic.dewdropsCost} dewdrops '
'but only $balance are available.',
);
}
await _storage.insertCompanionLedgerEntry(
CompanionLedgerEntry(
id: _ids.next(),
householdId: household.id,
memberId: memberId,
cosmeticId: cosmeticId,
dewdropsCost: cosmetic.dewdropsCost,
createdAt: _now(),
),
);
return _viewOf(household, companion, intensity: _defaultIntensity);
}

/// Pure state write: only an OWNED cosmetic can be equipped; equipping
/// replaces the same-slot item (one per slot).
Future<CompanionView> equipCosmetic({
required String memberId,
required String cosmeticId,
}) async {
final household = await _requireHousehold();
await _requireMember(household.id, memberId);
await _assertActorSelf(memberId);
final cosmetic = companionCosmeticById(cosmeticId);
if (cosmetic == null) {
throw UnknownCosmeticException(
'No cosmetic with id $cosmeticId in the catalog.',
);
}
final companion = await _requireCompanion(household, memberId);
final ledger = await _storage.getCompanionLedgerEntries(
household.id,
memberId: memberId,
);
if (!ledger.any((e) => e.cosmeticId == cosmeticId)) {
throw CosmeticNotOwnedException(
'$cosmeticId is not owned yet — buy it first.',
);
}
final equipped = <String>[
for (final id in companion.equipped)
if (companionCosmeticById(id)?.slot != cosmetic.slot) id,
cosmeticId,
];
final updated = await _storage.updateCompanion(
companion.copyWith(equipped: equipped),
);
return _viewOf(household, updated, intensity: _defaultIntensity);
}

/// Pure state write: trimmed, non-empty, capped at
/// [kCompanionNameMaxLength] — mirrors the member display-name validation
/// shape (trim / non-empty / cap) with the companion's tighter cap.
Future<CompanionView> renameCompanion({
required String memberId,
required String name,
}) async {
final household = await _requireHousehold();
await _requireMember(household.id, memberId);
await _assertActorSelf(memberId);
final trimmed = name.trim();
if (trimmed.isEmpty) {
throw const ValidationException('Companion name must not be empty.');
}
if (trimmed.length > kCompanionNameMaxLength) {
throw ValidationException(
'Companion name must be at most $kCompanionNameMaxLength characters.',
);
}
final companion = await _requireCompanion(household, memberId);
final updated = await _storage.updateCompanion(
companion.copyWith(name: trimmed),
);
return _viewOf(household, updated, intensity: _defaultIntensity);
}

static const CompanionMessinessIntensity _defaultIntensity =
CompanionMessinessIntensity.full;

// ── Internals ─────────────────────────────────────────────────────────────

Future<CompanionView> _viewOf(
Household household,
Companion companion, {
required CompanionMessinessIntensity intensity,
}) async {
final completions = await _storage.getCompletions(
household.id,
memberId: companion.memberId,
);
final ledger = await _storage.getCompanionLedgerEntries(
household.id,
memberId: companion.memberId,
);
final earned = completions.length * kDewdropsPerCompletion;
final spent = ledger.fold<int>(0, (sum, e) => sum + e.dewdropsCost);
// Zero-floored at the fold (mirrors the token zero-floor): a race or
// replay can never surface a negative dewdrop balance.
final balance = math.max(0, earned - spent);
DateTime? last;
for (final completion in completions) {
final at = completion.completedAt;
if (at != null && (last == null || at.isAfter(last))) last = at;
}
// No completions ever => 0 days idle => tidy (forgiving default: a
// brand-new kid never starts in a messy room).
final daysIdle = last == null ? 0 : _now().difference(last).inDays;
return CompanionView(
companion: companion,
lifetimeCompletions: completions.length,
dewdropsEarned: earned,
dewdropsSpent: spent,
dewdropsBalance: balance,
stage: stageFor(completions.length),
ownedCosmeticIds: {for (final e in ledger) e.cosmeticId},
messiness: cozyMessiness(
daysSinceLastCompletion: daysIdle,
intensity: intensity,
),
);
}

Future<Household> _requireHousehold() async {
final household = await _storage.getHousehold();
if (household == null) {
throw const DomainRuleException('No household exists yet.');
}
return household;
}

Future<HouseholdMember> _requireMember(
String householdId,
String memberId,
) async {
final members = await _storage.getMembers(householdId);
for (final member in members) {
if (member.id == memberId) return member;
}
throw const DomainRuleException('No such member in this household.');
}

Future<HouseholdMember?> _actor() async {
final resolve = _currentMember;
return resolve == null ? null : resolve();
}

/// Read gate: self, or a PARENTAL member of the same household. A sibling
/// child gets AuthorizationFailure — never a silent cross-member read.
Future<void> _assertActorMayRead(String householdId, String memberId) async {
final actor = await _actor();
if (actor == null) return; // local tier: no session — RLS is the backstop
if (actor.id == memberId) return;
if (actor.kind.isParental && actor.householdId == householdId) return;
throw AuthorizationFailure(
'member ${actor.id} may not view the companion of $memberId',
);
}

/// Mutation gate: SELF ONLY (the kid owns the creature; no parent write in
/// v1). Fails at the service half; the self-only RLS policies are the
/// schema half of the dual gate.
Future<void> _assertActorSelf(String memberId) async {
final actor = await _actor();
if (actor == null) return; // local tier: no session — RLS is the backstop
if (actor.id != memberId) {
throw AuthorizationFailure(
'member ${actor.id} may not modify the companion of $memberId',
);
}
}

Future<Companion> _requireCompanion(
Household household,
String memberId,
) async {
final existing = await _storage.getCompanion(memberId);
if (existing != null) return existing;
return _storage.insertCompanion(
Companion(
memberId: memberId,
householdId: household.id,
species: kCompanionSpecies,
createdAt: _now(),
),
);
}
}
  • Step 4: Run test to verify it passes

Run (from packages/client_sdk/): fvm flutter test test/companion_service_test.dart Expected: PASS — all groups (earn/zero-floor/purchase/equip/rename/growth/messiness/authz/create-on-first-read) green.

  • Step 5: Commit
git add packages/client_sdk/lib/src/services/companion_service.dart packages/client_sdk/test/companion_service_test.dart
git commit -m "feat: CompanionService — dewdrop economy, behavior-gated growth, cozy-messiness, actor authz"

Task 5: Drift local mirror (write-through cache's durable tier)

Files:

  • Modify: packages/client_sdk/lib/src/adapters/local/local_database.dart (2 new tables, @DriftDatabase list, schemaVersion 26 → 27, onUpgrade step)
  • Modify: packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart (5 port methods)
  • Generated: packages/client_sdk/lib/src/adapters/local/local_database.g.dart (build_runner, filtered)
  • Test: packages/client_sdk/test/local_storage_adapter_test.dart (extend with a companion round-trip group)

Interfaces:

  • Consumes: Companion, CompanionLedgerEntry, kCompanionSpecies (Task 2); StoragePort signatures (Task 3); Drift table style of local_database.dart (@DataClassName, JSON-text lists like roles/traits — SQLite has no array type).

  • Produces: Drift tables MemberCompanions (row class CompanionRow) and CompanionLedgerRows (row class CompanionLedgerRow), and LocalStorageAdapter implementations of the five Task-3 methods.

  • Step 1: Write the failing test

Append to packages/client_sdk/test/local_storage_adapter_test.dart (inside main(), after the existing groups — it already has db/adapter/seedHousehold() in scope):

group('companion round-trip', () {
test('insert -> getCompanion reads back every field (JSON-text equipped '
'list survives)', () async {
await seedHousehold();
final companion = Companion(
memberId: 'm1',
householdId: 'h1',
species: kCompanionSpecies,
name: 'Fern',
equipped: const ['hat_sprout_cap', 'color_moss'],
createdAt: DateTime(2026, 7, 18),
);
await adapter.insertCompanion(companion);
expect(await adapter.getCompanion('m1'), companion);
expect(await adapter.getCompanion('missing'), isNull);
});

test('updateCompanion persists rename + equip', () async {
await seedHousehold();
final companion = Companion(
memberId: 'm1',
householdId: 'h1',
species: kCompanionSpecies,
createdAt: DateTime(2026, 7, 18),
);
await adapter.insertCompanion(companion);
await adapter.updateCompanion(
companion.copyWith(name: 'Moss', equipped: const ['hat_acorn']),
);
final read = await adapter.getCompanion('m1');
expect(read!.name, 'Moss');
expect(read.equipped, const ['hat_acorn']);
});

test('companion ledger appends and filters by household + member',
() async {
await seedHousehold();
final e1 = CompanionLedgerEntry(
id: 'l1',
householdId: 'h1',
memberId: 'm1',
cosmeticId: 'hat_sprout_cap',
dewdropsCost: 5,
createdAt: DateTime(2026, 7, 18),
);
final e2 = CompanionLedgerEntry(
id: 'l2',
householdId: 'h1',
memberId: 'm2',
cosmeticId: 'color_moss',
dewdropsCost: 5,
createdAt: DateTime(2026, 7, 18),
);
await adapter.insertCompanionLedgerEntry(e1);
await adapter.insertCompanionLedgerEntry(e2);
expect(await adapter.getCompanionLedgerEntries('h1'), [e1, e2]);
expect(
await adapter.getCompanionLedgerEntries('h1', memberId: 'm1'),
[e1],
);
});
});
  • Step 2: Run test to verify it fails

Run (from packages/client_sdk/): fvm flutter test test/local_storage_adapter_test.dart Expected: FAIL — the adapter bodies reference _db.memberCompanions, which does not exist until the tables + codegen land (compile error); or the new group fails on missing methods.

  • Step 3: Write minimal implementation

Add to packages/client_sdk/lib/src/adapters/local/local_database.dart (after the Entitlements table class, mirroring the house table style):

/// Mirrors `20260718000100_companion_creature.sql` — ONE row per kid. The
/// `equipped` text[] is stored as JSON text exactly like the member
/// roles/traits aggregates (SQLite has no array type).
@DataClassName('CompanionRow')
class MemberCompanions extends Table {
TextColumn get memberId => text()();
TextColumn get householdId =>
text().references(Households, #id, onDelete: KeyAction.cascade)();

/// Fixed 'sprout' in v1 (the SQL CHECK twin).
TextColumn get species => text().withDefault(const Constant('sprout'))();
TextColumn get name => text().nullable()();

/// JSON-encoded list of cosmetic-id strings, e.g. '["hat_sprout_cap"]'.
TextColumn get equipped => text().withDefault(const Constant('[]'))();
DateTimeColumn get createdAt => dateTime().nullable()();

@override
Set<Column<Object>> get primaryKey => {memberId};

@override
List<String> get customConstraints => [
"CHECK (species = 'sprout')",
];
}

/// Mirrors `20260718000100_companion_creature.sql`. APPEND-ONLY like
/// [LedgerEntries]: the adapter exposes insert + select only — no update or
/// delete method exists for this table anywhere in the SDK. The dewdrop zero
/// floor is enforced in CompanionService (and by the Postgres trigger in the
/// cloud schema); SQLite CHECKs cannot see other rows, so no local twin.
@DataClassName('CompanionLedgerRow')
class CompanionLedgerRows extends Table {
TextColumn get id => text()();
TextColumn get householdId =>
text().references(Households, #id, onDelete: KeyAction.cascade)();
TextColumn get memberId => text()();
TextColumn get cosmeticId => text()();
IntColumn get dewdropsCost => integer()();
DateTimeColumn get createdAt => dateTime().nullable()();

@override
Set<Column<Object>> get primaryKey => {id};

@override
List<Set<Column<Object>>> get uniqueKeys => [
{memberId, cosmeticId},
];

@override
List<String> get customConstraints => ['CHECK (dewdrops_cost > 0)'];
}

In the @DriftDatabase(tables: [...]) annotation add MemberCompanions, CompanionLedgerRows, after Entitlements. Bump schemaVersion from 26 to 27, and append to onUpgrade (after the if (from < 26) block):

if (from < 27) {
// Companion creature (Tier 1): the per-kid companion row + its
// APPEND-ONLY cosmetic spend ledger, physically separate from
// ledger_entries (dewdrops are never convertible to tokens). The
// local twin of 20260718000100_companion_creature.sql. Brand-new
// tables, so createTable is additive.
await m.createTable(memberCompanions);
await m.createTable(companionLedgerRows);
}

Add to packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart (a new section after the ledger section; add import 'dart:convert'; if it is not already imported for the roles/traits JSON aggregates):

// ── Companion (member_companion + append-only companion_ledger) ──

@override
Future<Companion?> getCompanion(String memberId) async {
final row = await (_db.select(
_db.memberCompanions,
)..where((t) => t.memberId.equals(memberId))).getSingleOrNull();
return row == null ? null : _companionFromRow(row);
}

@override
Future<Companion> insertCompanion(Companion companion) async {
await _db
.into(_db.memberCompanions)
.insert(
MemberCompanionsCompanion.insert(
memberId: companion.memberId,
householdId: companion.householdId,
species: Value(companion.species),
name: Value(companion.name),
equipped: Value(jsonEncode(companion.equipped)),
createdAt: Value(companion.createdAt),
),
);
return companion;
}

@override
Future<Companion> updateCompanion(Companion companion) async {
await (_db.update(_db.memberCompanions)
..where((t) => t.memberId.equals(companion.memberId)))
.write(
MemberCompanionsCompanion(
name: Value(companion.name),
equipped: Value(jsonEncode(companion.equipped)),
),
);
return companion;
}

Companion _companionFromRow(CompanionRow row) => Companion(
memberId: row.memberId,
householdId: row.householdId,
species: row.species,
name: row.name,
equipped: (jsonDecode(row.equipped) as List<dynamic>).cast<String>(),
createdAt: row.createdAt,
);

@override
Future<List<CompanionLedgerEntry>> getCompanionLedgerEntries(
String householdId, {
String? memberId,
}) async {
final query = _db.select(_db.companionLedgerRows)
..where((t) => t.householdId.equals(householdId));
if (memberId != null) {
query.where((t) => t.memberId.equals(memberId));
}
final rows = await query.get();
return rows
.map(
(row) => CompanionLedgerEntry(
id: row.id,
householdId: row.householdId,
memberId: row.memberId,
cosmeticId: row.cosmeticId,
dewdropsCost: row.dewdropsCost,
createdAt: row.createdAt,
),
)
.toList();
}

@override
Future<CompanionLedgerEntry> insertCompanionLedgerEntry(
CompanionLedgerEntry entry,
) async {
await _db
.into(_db.companionLedgerRows)
.insert(
CompanionLedgerRowsCompanion.insert(
id: entry.id,
householdId: entry.householdId,
memberId: entry.memberId,
cosmeticId: entry.cosmeticId,
dewdropsCost: entry.dewdropsCost,
createdAt: Value(entry.createdAt),
),
);
return entry;
}

Then regenerate the Drift code (FILTERED — never a bare build):

Run (from packages/client_sdk/): fvm dart run build_runner build --build-filter "lib/src/adapters/local/local_database.g.dart" If any hand-maintained .g.dart (app state.g.dart / router.gr.dart) is clobbered anyway: git checkout HEAD -- <file> to restore, then re-run with the filter.

  • Step 4: Run test to verify it passes

Run (from packages/client_sdk/): fvm flutter test test/local_storage_adapter_test.dart Expected: PASS — companion round-trip group green, existing groups untouched.

  • Step 5: Commit
git add packages/client_sdk/lib/src/adapters/local/local_database.dart packages/client_sdk/lib/src/adapters/local/local_database.g.dart packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart packages/client_sdk/test/local_storage_adapter_test.dart
git commit -m "feat: companion Drift mirror — tables v27 + local adapter round-trip"

Task 6: Cloud PostgREST mixin + codec, and cache-first write-through

Files:

  • Create: packages/client_sdk/lib/src/adapters/cloud/supabase_companion.dart
  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart (add mixin)
  • Modify: packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart (5 methods + hydration)
  • Test: packages/client_sdk/test/cloud/companion_routing_test.dart
  • Test: packages/client_sdk/test/cached_storage_adapter_test.dart (extend with a companion group)

Interfaces:

  • Consumes: PostgrestPort (selectEq / selectMaybeSingle / insert / updateEq from cloud_rows.dart), the dtN/isoN codec helpers used by supabase_economy.dart, table names from Task 1 (member_companion, companion_ledger).

  • Produces: mixin CompanionStore on SupabaseStorageAdapter implementing the five Task-3 methods; CachedStorageAdapter delegation (reads from cache, write-through durable-first) + hydration of both companion aggregates.

  • Step 1: Write the failing test

packages/client_sdk/test/cloud/companion_routing_test.dart (self-contained in-memory PostgrestPort — the codec is the ONLY thing under test):

import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk/src/adapters/cloud/cloud_rows.dart';
import 'package:client_sdk/src/adapters/cloud/supabase_storage_adapter.dart';
import 'package:flutter_test/flutter_test.dart';

/// Minimal in-memory PostgrestPort: rows are stored as the raw maps the
/// adapter writes, and returned as PostgREST would return them.
class _MemPort implements PostgrestPort {
final Map<String, List<Map<String, dynamic>>> tables = {
'member_companion': <Map<String, dynamic>>[],
'companion_ledger': <Map<String, dynamic>>[],
};

@override
Future<void> insert(String t, Map<String, dynamic> v) async =>
tables[t]!.add(Map<String, dynamic>.of(v));

@override
Future<List<Map<String, dynamic>>> selectEq(
String t,
Map<String, Object?> f, {
String? orderBy,
bool ascending = true,
int? limit,
}) async => tables[t]!
.where((r) => f.entries.every((e) => r[e.key] == e.value))
.toList();

@override
Future<Map<String, dynamic>?> selectMaybeSingle(
String t,
Map<String, Object?> f,
) async {
final rows = await selectEq(t, f);
return rows.isEmpty ? null : rows.first;
}

@override
Future<void> updateEq(
String t,
Map<String, dynamic> v,
Map<String, Object?> f,
) async {
for (final row in await selectEq(t, f)) {
row.addAll(v);
}
}

@override
Object? noSuchMethod(Invocation invocation) =>
throw UnimplementedError('${invocation.memberName}');
}

void main() {
late _MemPort port;
late SupabaseStorageAdapter adapter;

setUp(() {
port = _MemPort();
adapter = SupabaseStorageAdapter.forTest(port);
});

test('companion row round-trips through the snake_case codec, '
'including the equipped text[]', () async {
final companion = Companion(
memberId: 'c1',
householdId: 'h1',
species: kCompanionSpecies,
name: 'Fern',
equipped: const ['hat_sprout_cap'],
createdAt: DateTime.utc(2026, 7, 18),
);
await adapter.insertCompanion(companion);
expect(port.tables['member_companion']!.single['member_id'], 'c1');
expect(port.tables['member_companion']!.single['equipped'],
const ['hat_sprout_cap']);
expect(await adapter.getCompanion('c1'), companion);
expect(await adapter.getCompanion('missing'), isNull);
});

test('updateCompanion never overwrites member_id/created_at (the '
'server-managed columns rule)', () async {
final companion = Companion(
memberId: 'c1',
householdId: 'h1',
species: kCompanionSpecies,
createdAt: DateTime.utc(2026, 7, 18),
);
await adapter.insertCompanion(companion);
await adapter.updateCompanion(
companion.copyWith(name: 'Moss', equipped: const ['hat_acorn']),
);
final read = await adapter.getCompanion('c1');
expect(read!.name, 'Moss');
expect(read.equipped, const ['hat_acorn']);
expect(read.createdAt, companion.createdAt);
});

test('companion ledger appends + filters by household/member', () async {
final entry = CompanionLedgerEntry(
id: 'l1',
householdId: 'h1',
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
dewdropsCost: 5,
createdAt: DateTime.utc(2026, 7, 18),
);
await adapter.insertCompanionLedgerEntry(entry);
expect(await adapter.getCompanionLedgerEntries('h1'), [entry]);
expect(
await adapter.getCompanionLedgerEntries('h1', memberId: 'c1'),
[entry],
);
expect(await adapter.getCompanionLedgerEntries('h1', memberId: 'cX'),
isEmpty);
});
}
  • Step 2: Run test to verify it fails

Run (from packages/client_sdk/): fvm flutter test test/cloud/companion_routing_test.dart Expected: FAIL — SupabaseStorageAdapter has no CompanionStore mixin yet (compile error on insertCompanion), or UnimplementedError from missing routing if stubs pre-landed in Task 3.

  • Step 3: Write minimal implementation

packages/client_sdk/lib/src/adapters/cloud/supabase_companion.dart (new file, complete — mirrors the Economy mixin shape):

import '../../models/companion.dart';
import 'cloud_rows.dart';

/// Cloud routing + codec for the companion tables. Pure I/O — zero domain
/// logic. The companion_ledger surface is APPEND-ONLY (insert + read only),
/// exactly like the token ledger.
mixin CompanionStore {
PostgrestPort get db;

// ── member_companion ─────────────────────────────────────────────────────

Future<Companion?> getCompanion(String memberId) async {
final row =
await db.selectMaybeSingle('member_companion', {'member_id': memberId});
return row == null ? null : _companion(row);
}

Future<Companion> insertCompanion(Companion companion) async {
await db.insert('member_companion', _companionValues(companion));
return companion;
}

Future<Companion> updateCompanion(Companion companion) async {
// member_id is the PK filter; created_at is server-managed: never
// overwrite on update (house rule, see updateApproval).
await db.updateEq(
'member_companion',
_companionValues(companion)
..remove('member_id')
..remove('created_at'),
{'member_id': companion.memberId},
);
return companion;
}

// ── companion_ledger (append-only: insert + read only) ───────────────────

Future<List<CompanionLedgerEntry>> getCompanionLedgerEntries(
String householdId, {
String? memberId,
}) async {
final rows = await db.selectEq('companion_ledger', {
'household_id': householdId,
// ignore: use_null_aware_elements — we want OMISSION not null value
if (memberId != null) 'member_id': memberId,
});
return rows.map(_companionLedgerEntry).toList();
}

Future<CompanionLedgerEntry> insertCompanionLedgerEntry(
CompanionLedgerEntry entry,
) async {
await db.insert('companion_ledger', _companionLedgerValues(entry));
return entry;
}

// ── Codec (snake_case rows <-> models, house style) ──────────────────────

Companion _companion(Map<String, dynamic> r) => Companion(
memberId: r['member_id'] as String,
householdId: r['household_id'] as String,
species: r['species'] as String,
name: r['name'] as String?,
equipped: ((r['equipped'] as List<dynamic>?) ?? const <dynamic>[])
.cast<String>(),
createdAt: dtN(r['created_at']),
);

Map<String, dynamic> _companionValues(Companion c) => {
'member_id': c.memberId,
'household_id': c.householdId,
'species': c.species,
'name': c.name,
'equipped': c.equipped,
'created_at': isoN(c.createdAt),
};

CompanionLedgerEntry _companionLedgerEntry(Map<String, dynamic> r) =>
CompanionLedgerEntry(
id: r['id'] as String,
householdId: r['household_id'] as String,
memberId: r['member_id'] as String,
cosmeticId: r['cosmetic_id'] as String,
dewdropsCost: r['dewdrops_cost'] as int,
createdAt: dtN(r['created_at']),
);

Map<String, dynamic> _companionLedgerValues(CompanionLedgerEntry e) => {
'id': e.id,
'household_id': e.householdId,
'member_id': e.memberId,
'cosmetic_id': e.cosmeticId,
'dewdrops_cost': e.dewdropsCost,
'created_at': isoN(e.createdAt),
};
}

Modify packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart: add import 'supabase_companion.dart'; and extend the mixin clause:

class SupabaseStorageAdapter
with Households, Chores, Economy, Catalog, Consents, CompanionStore
implements StoragePort, ConsentPort {

Modify packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart — add import '../../models/companion.dart'; and the five delegating methods (reads from cache, write-through durable-first, mirroring every other aggregate):

// ── Companion (cache-first reads, write-through mutations) ──────────────

@override
Future<Companion?> getCompanion(String memberId) async {
await _ensureHydrated();
return _cache.getCompanion(memberId);
}

@override
Future<Companion> insertCompanion(Companion companion) async {
await _ensureHydrated();
await _durable.insertCompanion(companion);
return _cache.insertCompanion(companion);
}

@override
Future<Companion> updateCompanion(Companion companion) async {
await _ensureHydrated();
await _durable.updateCompanion(companion);
return _cache.updateCompanion(companion);
}

@override
Future<List<CompanionLedgerEntry>> getCompanionLedgerEntries(
String householdId, {
String? memberId,
}) async {
await _ensureHydrated();
return _cache.getCompanionLedgerEntries(householdId, memberId: memberId);
}

@override
Future<CompanionLedgerEntry> insertCompanionLedgerEntry(
CompanionLedgerEntry entry,
) async {
await _ensureHydrated();
await _durable.insertCompanionLedgerEntry(entry);
return _cache.insertCompanionLedgerEntry(entry);
}

and in _hydrate, extend the existing member loop + add the ledger load. The member loop becomes:

for (final member in await _durable.getMembers(id)) {
await _cache.insertMember(member);
final companion = await _durable.getCompanion(member.id);
if (companion != null) await _cache.insertCompanion(companion);
}

and after the getLedgerEntries hydration block add:

for (final entry in await _durable.getCompanionLedgerEntries(id)) {
await _cache.insertCompanionLedgerEntry(entry);
}

Append to packages/client_sdk/test/cached_storage_adapter_test.dart (inside main()):

group('companion cache-first + write-through', () {
test('mutations persist durable-first; a REBUILT cache hydrates the '
'companion + its ledger from durable', () async {
final durable = InMemoryStorageAdapter();
var cached = CachedStorageAdapter(durable: durable);
await cached.insertHousehold(const Household(id: 'h1', name: 'Casa'));
await cached.insertMember(
const HouseholdMember(
id: 'c1',
householdId: 'h1',
displayName: 'Sam',
kind: MemberKind.child,
),
);
const companion = Companion(
memberId: 'c1',
householdId: 'h1',
species: kCompanionSpecies,
name: 'Fern',
);
await cached.insertCompanion(companion);
await cached.insertCompanionLedgerEntry(
const CompanionLedgerEntry(
id: 'l1',
householdId: 'h1',
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
dewdropsCost: 5,
),
);
// Write-through hit durable synchronously:
expect(await durable.getCompanion('c1'), companion);
// A fresh cached adapter over the same durable hydrates both:
cached = CachedStorageAdapter(durable: durable);
expect(await cached.getCompanion('c1'), companion);
expect(
(await cached.getCompanionLedgerEntries('h1', memberId: 'c1'))
.single
.cosmeticId,
'hat_sprout_cap',
);
});
});
  • Step 4: Run test to verify it passes

Run (from packages/client_sdk/): fvm flutter test test/cloud/companion_routing_test.dart test/cached_storage_adapter_test.dart Expected: PASS — codec round-trip + write-through/hydration green.

  • Step 5: Commit
git add packages/client_sdk/lib/src/adapters/cloud/supabase_companion.dart packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart packages/client_sdk/test/cloud/companion_routing_test.dart packages/client_sdk/test/cached_storage_adapter_test.dart
git commit -m "feat: companion cloud PostgREST mixin + codec, cache-first write-through"

Task 7: Client facade passthroughs

Files:

  • Modify: packages/client_sdk/lib/src/client/client.dart (abstract facade — 4 methods, after getCompletions at ~line 528)
  • Modify: packages/client_sdk/lib/src/client/client_impl.dart (wire CompanionService in clientFromPort, field + passthroughs in ClientImpl)
  • Test: packages/client_sdk/test/companion_facade_test.dart

Interfaces:

  • Consumes: CompanionService (Task 4), clientFromPort(StoragePort storage, {DateTime Function()? now, ClientAuth? auth, ...}) and the resolvedAuth.currentUser?.id → storage.memberByAuthUserId(uid) actor seam already used for EconomyService.

  • Produces (called verbatim by CompanionRepository in Task 8):

    • Future<CompanionView> getCompanion(String memberId)
    • Future<CompanionView> purchaseCosmetic({required String memberId, required String cosmeticId})
    • Future<CompanionView> equipCosmetic({required String memberId, required String cosmeticId})
    • Future<CompanionView> renameCompanion({required String memberId, required String name})
  • Step 1: Write the failing test

packages/client_sdk/test/companion_facade_test.dart:

import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk/src/client/client_impl.dart';
import 'package:flutter_test/flutter_test.dart';

import 'support/fake_port.dart';

void main() {
test('facade passthroughs round-trip: getCompanion -> purchase -> equip -> '
'rename, ONE aggregate per call', () async {
final port = FakePort();
await port.insertHousehold(const Household(id: 'h1', name: 'Casa'));
await port.insertMember(
const HouseholdMember(
id: 'c1',
householdId: 'h1',
displayName: 'Sam',
kind: MemberKind.child,
age: 8,
consentState: ConsentState.granted,
),
);
for (var i = 0; i < 10; i++) {
await port.insertCompletion(
ChoreCompletion(
id: 'comp-$i',
householdId: 'h1',
choreId: 'ch',
memberId: 'c1',
completedAt: DateTime(2026, 7, 17),
),
);
}
final client = clientFromPort(port);

final view = await client.getCompanion('c1');
expect(view.dewdropsBalance, 10);
expect(view.stage, CompanionGrowthStage.sprout);
expect(view.messiness, CompanionMessinessTier.tidy);

final bought = await client.purchaseCosmetic(
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
);
expect(bought.dewdropsBalance, 5);
expect(bought.ownedCosmeticIds, {'hat_sprout_cap'});

final equipped = await client.equipCosmetic(
memberId: 'c1',
cosmeticId: 'hat_sprout_cap',
);
expect(equipped.companion.equipped, contains('hat_sprout_cap'));

final named = await client.renameCompanion(memberId: 'c1', name: 'Fern');
expect(named.companion.name, 'Fern');
});
}
  • Step 2: Run test to verify it fails

Run (from packages/client_sdk/): fvm flutter test test/companion_facade_test.dart Expected: FAIL — compile error: getCompanion is not defined for Client.

  • Step 3: Write minimal implementation

Add to packages/client_sdk/lib/src/client/client.dart (in the abstract Client, directly after getCompletions({String? memberId})):

/// The member's companion + every derived value (dewdrop balance, growth
/// stage, owned cosmetics, cozy-messiness) in ONE aggregate — the app makes
/// a single call. Dewdrops are derived from [ChoreCompletion] rows (the
/// A3/A4 guard: expectation-only kids still earn) and are NEVER convertible
/// to tokens. Create-on-first-read: a SELF read persists the default row.
Future<CompanionView> getCompanion(String memberId);

/// Buys a catalog cosmetic with dewdrops (append-only companion_ledger).
/// Throws [UnknownCosmeticException], [CosmeticAlreadyOwnedException]
/// (idempotent, no double-charge) or [InsufficientDewdropsException].
Future<CompanionView> purchaseCosmetic({
required String memberId,
required String cosmeticId,
});

/// Equips an OWNED cosmetic (one per slot; replaces same-slot). Throws
/// [CosmeticNotOwnedException] / [UnknownCosmeticException].
Future<CompanionView> equipCosmetic({
required String memberId,
required String cosmeticId,
});

/// Renames the companion (trimmed, non-empty, <= kCompanionNameMaxLength).
/// Throws [ValidationException].
Future<CompanionView> renameCompanion({
required String memberId,
required String name,
});

Modify packages/client_sdk/lib/src/client/client_impl.dart:

  1. Add import '../services/companion_service.dart'; beside the other service imports.
  2. In clientFromPort, EXTRACT the actor seam currently inlined for EconomyService into a shared local function (immediately after final resolvedAuth = auth ?? const NoopAuth();):
// The AUTHENTICATED account's member — the single actor seam shared by
// EconomyService (catalog dual gate) and CompanionService (self-only
// companion mutations). Null on the free/local tier (NoopAuth => no user).
Future<HouseholdMember?> currentMember() async {
final uid = resolvedAuth.currentUser?.id;
if (uid == null) return null;
return storage.memberByAuthUserId(uid);
}

then replace the currentMember: () async { ... } closure argument of EconomyService with currentMember: currentMember, and add to the ClientImpl._ construction:

companionService: CompanionService(
storage: storage,
now: clock,
currentMember: currentMember,
),
  1. In ClientImpl: add the constructor parameter, initializer, and field mirroring the other services:
required CompanionService companionService,
_companionService = companionService,
final CompanionService _companionService;

and the passthroughs (pure delegation, zero logic — next to getCompletions):

@override
Future<CompanionView> getCompanion(String memberId) =>
_companionService.getCompanion(memberId);

@override
Future<CompanionView> purchaseCosmetic({
required String memberId,
required String cosmeticId,
}) => _companionService.purchaseCosmetic(
memberId: memberId,
cosmeticId: cosmeticId,
);

@override
Future<CompanionView> equipCosmetic({
required String memberId,
required String cosmeticId,
}) => _companionService.equipCosmetic(
memberId: memberId,
cosmeticId: cosmeticId,
);

@override
Future<CompanionView> renameCompanion({
required String memberId,
required String name,
}) => _companionService.renameCompanion(memberId: memberId, name: name);

NOTE: client_sdk_testing's MockClient is a mocktail Mock implements Client, so it inherits the new members without edits. If createInMemoryClient or demo_seed construct a Client subclass manually and now fail to compile, add the same four passthroughs there delegating to clientFromPort's instance.

  • Step 4: Run test to verify it passes

Run (from packages/client_sdk/): fvm flutter test test/companion_facade_test.dart Expected: PASS. Then run the full SDK suite: fvm flutter test Expected: PASS, ≥ 1049 + new companion tests.

  • Step 5: Commit
git add packages/client_sdk/lib/src/client/client.dart packages/client_sdk/lib/src/client/client_impl.dart packages/client_sdk/test/companion_facade_test.dart
git commit -m "feat: Client facade companion passthroughs (getCompanion/purchase/equip/rename)"

Task 8: App — CompanionRepository + CompanionCubit (achievements sibling)

Files:

  • Create: app/lib/outside/repositories/companion/companion_repository.dart
  • Create: app/lib/inside/blocs/companion/state.dart
  • Create: app/lib/inside/blocs/companion/cubit.dart
  • Modify: app/lib/outside/repositories/all.dart (register CompanionRepository)
  • Modify: app/lib/app/runner.dart (~line 167 area — construct it beside AchievementsRepository)
  • Modify: app/test/util/mocks/repositories.dart (add MockCompanionRepository)
  • Modify: app/test/util/mocks/mocked_app.dart (field + default stub in MocksContainer)
  • Modify: app/test/util/test_app_builder.dart (pass mocks.companionRepository into the RepositoriesAll it builds)
  • Test: app/test/blocs/companion_cubit_test.dart

Interfaces:

  • Consumes: facade methods from Task 7; SdkClientProvider (app/lib/outside/client_providers/sdk_client_provider.dart, exposes .client); RepositoryBase (app/lib/outside/repositories/base.dart); the AchievementsCubit/AchievementsState sibling shape.

  • Produces (consumed by Task 10 wiring + flow tests):

    • CompanionRepository{ Future<CompanionView?> loadForCurrentMember(); Future<CompanionView> purchaseCosmetic({required String memberId, required String cosmeticId}); Future<CompanionView> equipCosmetic({required String memberId, required String cosmeticId}); Future<CompanionView> renameCompanion({required String memberId, required String name}); }
    • CompanionCubit{ Future<void> load(); Future<void> reactToCompletion(String memberId); Future<void> purchase(String cosmeticId); Future<void> equip(String cosmeticId); Future<void> rename(String name); }
    • CompanionState{ CompanionStatus status; CompanionView? view; int reactionTick; String? failureMessage; }, enum CompanionStatus { initial, loading, loaded, failure }
  • Step 1: Write the failing test

app/test/blocs/companion_cubit_test.dart:

import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:household_app/inside/blocs/companion/cubit.dart';
import 'package:household_app/outside/repositories/companion/companion_repository.dart';
import 'package:mocktail/mocktail.dart';

class _MockCompanionRepository extends Mock implements CompanionRepository {}

CompanionView _view({
int balance = 3,
int spent = 0,
Set<String> owned = const {},
List<String> equipped = const [],
String? name,
}) {
return CompanionView(
companion: Companion(
memberId: 'sam',
householdId: 'h1',
species: kCompanionSpecies,
name: name,
equipped: equipped,
),
lifetimeCompletions: balance + spent,
dewdropsEarned: balance + spent,
dewdropsSpent: spent,
dewdropsBalance: balance,
stage: CompanionGrowthStage.seedling,
ownedCosmeticIds: owned,
messiness: CompanionMessinessTier.tidy,
);
}

void main() {
late _MockCompanionRepository repo;
late CompanionCubit cubit;

setUp(() {
repo = _MockCompanionRepository();
cubit = CompanionCubit(companionRepository: repo);
});

tearDown(() => cubit.close());

test('load: loading -> loaded(view); null view stays initial (no session)',
() async {
when(repo.loadForCurrentMember).thenAnswer((_) async => _view());
await cubit.load();
expect(cubit.state.status, CompanionStatus.loaded);
expect(cubit.state.view!.dewdropsBalance, 3);

when(repo.loadForCurrentMember).thenAnswer((_) async => null);
await cubit.load();
expect(cubit.state.status, CompanionStatus.initial);
});

test('load failure -> failure status', () async {
when(repo.loadForCurrentMember)
.thenThrow(const DomainRuleException('boom'));
await cubit.load();
expect(cubit.state.status, CompanionStatus.failure);
});

test('reactToCompletion for the OWN companion bumps reactionTick and '
'refreshes the view (+1 dewdrop)', () async {
when(repo.loadForCurrentMember).thenAnswer((_) async => _view());
await cubit.load();
when(repo.loadForCurrentMember).thenAnswer((_) async => _view(balance: 4));
await cubit.reactToCompletion('sam');
expect(cubit.state.reactionTick, 1);
expect(cubit.state.view!.dewdropsBalance, 4);
});

test('reactToCompletion for ANOTHER member is ignored (the ambient '
'creature belongs to the current member)', () async {
when(repo.loadForCurrentMember).thenAnswer((_) async => _view());
await cubit.load();
await cubit.reactToCompletion('someone-else');
expect(cubit.state.reactionTick, 0);
verify(repo.loadForCurrentMember).called(1);
});

test('purchase success replaces the view and clears failureMessage',
() async {
when(repo.loadForCurrentMember).thenAnswer((_) async => _view());
await cubit.load();
when(
() => repo.purchaseCosmetic(
memberId: 'sam',
cosmeticId: 'hat_sprout_cap',
),
).thenAnswer(
(_) async => _view(balance: 1, spent: 5, owned: {'hat_sprout_cap'}),
);
await cubit.purchase('hat_sprout_cap');
expect(cubit.state.view!.dewdropsBalance, 1);
expect(cubit.state.view!.ownedCosmeticIds, {'hat_sprout_cap'});
expect(cubit.state.failureMessage, isNull);
});

test('typed purchase failures surface as failureMessage without losing '
'the loaded view', () async {
when(repo.loadForCurrentMember).thenAnswer((_) async => _view());
await cubit.load();
when(
() => repo.purchaseCosmetic(
memberId: 'sam',
cosmeticId: 'hat_flower',
),
).thenThrow(const InsufficientDewdropsException('too few dewdrops'));
await cubit.purchase('hat_flower');
expect(cubit.state.status, CompanionStatus.loaded);
expect(cubit.state.failureMessage, 'too few dewdrops');
});

test('rename success persists the new name into state', () async {
when(repo.loadForCurrentMember).thenAnswer((_) async => _view());
await cubit.load();
when(
() => repo.renameCompanion(memberId: 'sam', name: 'Fern'),
).thenAnswer((_) async => _view(name: 'Fern'));
await cubit.rename('Fern');
expect(cubit.state.view!.companion.name, 'Fern');
});
}
  • Step 2: Run test to verify it fails

Run (from app/): fvm flutter test test/blocs/companion_cubit_test.dart Expected: FAIL — compile error: companion_repository.dart / companion/cubit.dart do not exist.

  • Step 3: Write minimal implementation

app/lib/outside/repositories/companion/companion_repository.dart (new file, complete):

import 'package:client_sdk/client_sdk.dart';

import '../../client_providers/sdk_client_provider.dart';
import '../base.dart';

/// Thin presentation delegate for the companion creature — ONE data path:
/// CompanionCubit -> this repository -> Client facade -> CompanionService.
/// Cache-first comes free: the SDK's CachedStorageAdapter serves every read
/// from memory; mutations write through durable-first.
class CompanionRepository extends RepositoryBase {
const CompanionRepository({required SdkClientProvider clientProvider})
: _clientProvider = clientProvider;

final SdkClientProvider _clientProvider;

/// Resolves the signed-in account's member, then loads their companion
/// aggregate in ONE facade call. Null when there is no session or no
/// member yet (pre-auth, mid-bootstrap) — the ambient layer renders
/// nothing.
Future<CompanionView?> loadForCurrentMember() async {
final client = _clientProvider.client;
final authUserId = client.auth.currentUser?.id;
if (authUserId == null) return null;
final member = await client.memberForAccount(authUserId);
if (member == null) return null;
return client.getCompanion(member.id);
}

Future<CompanionView> purchaseCosmetic({
required String memberId,
required String cosmeticId,
}) => _clientProvider.client.purchaseCosmetic(
memberId: memberId,
cosmeticId: cosmeticId,
);

Future<CompanionView> equipCosmetic({
required String memberId,
required String cosmeticId,
}) => _clientProvider.client.equipCosmetic(
memberId: memberId,
cosmeticId: cosmeticId,
);

Future<CompanionView> renameCompanion({
required String memberId,
required String name,
}) => _clientProvider.client.renameCompanion(
memberId: memberId,
name: name,
);
}

app/lib/inside/blocs/companion/state.dart (new file, complete):

import 'package:client_sdk/client_sdk.dart';
import 'package:equatable/equatable.dart';

enum CompanionStatus { initial, loading, loaded, failure }

/// Companion presentation state. [reactionTick] increments on every
/// completion by the companion's owner — the DS creature plays one happy
/// bounce per tick (the SAME celebration signal, no new plumbing).
class CompanionState extends Equatable {
const CompanionState({
this.status = CompanionStatus.initial,
this.view,
this.reactionTick = 0,
this.failureMessage,
});

final CompanionStatus status;
final CompanionView? view;
final int reactionTick;

/// A typed-failure message for the purchase/equip/rename dialogs; null
/// when the last action succeeded.
final String? failureMessage;

CompanionState copyWith({
CompanionStatus? status,
CompanionView? view,
int? reactionTick,
String? failureMessage,
bool clearFailure = false,
}) => CompanionState(
status: status ?? this.status,
view: view ?? this.view,
reactionTick: reactionTick ?? this.reactionTick,
failureMessage:
clearFailure ? null : (failureMessage ?? this.failureMessage),
);

@override
List<Object?> get props => [status, view, reactionTick, failureMessage];
}

app/lib/inside/blocs/companion/cubit.dart (new file, complete — sibling of achievements/cubit.dart, same imports style):

import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../../../shared/mixins/logging.dart';
import '../../../../outside/repositories/companion/companion_repository.dart';
import 'state.dart';

export 'state.dart';

/// Loads and mutates the current member's companion. All domain rules live in
/// the SDK's CompanionService — this cubit only orchestrates presentation.
class CompanionCubit extends Cubit<CompanionState> with LoggingMixin {
CompanionCubit({required CompanionRepository companionRepository})
: _repo = companionRepository,
super(const CompanionState());

final CompanionRepository _repo;

/// Loads the current member's companion aggregate. Idempotent. A null view
/// (no session / no member) resets to initial so the layer renders nothing.
Future<void> load() async {
emit(state.copyWith(status: CompanionStatus.loading));
try {
final view = await _repo.loadForCurrentMember();
if (view == null) {
emit(state.copyWith(status: CompanionStatus.initial));
return;
}
emit(state.copyWith(status: CompanionStatus.loaded, view: view));
} on Exception catch (e, st) {
log.warning('CompanionCubit.load failed', e, st);
emit(state.copyWith(status: CompanionStatus.failure));
}
}

/// Fired off the SAME ChoreDoneState celebration diff the overlay uses
/// (TodayCelebrationHost). Only reacts when the completing member OWNS the
/// ambient companion; the refresh picks up the +1 dewdrop.
Future<void> reactToCompletion(String memberId) async {
final view = state.view;
if (view == null || view.companion.memberId != memberId) return;
emit(state.copyWith(reactionTick: state.reactionTick + 1));
try {
final refreshed = await _repo.loadForCurrentMember();
if (refreshed != null) {
emit(state.copyWith(status: CompanionStatus.loaded, view: refreshed));
}
} on Exception catch (e, st) {
log.warning('CompanionCubit.reactToCompletion refresh failed', e, st);
}
}

Future<void> purchase(String cosmeticId) => _mutate(
(memberId) =>
_repo.purchaseCosmetic(memberId: memberId, cosmeticId: cosmeticId),
);

Future<void> equip(String cosmeticId) => _mutate(
(memberId) =>
_repo.equipCosmetic(memberId: memberId, cosmeticId: cosmeticId),
);

Future<void> rename(String name) => _mutate(
(memberId) => _repo.renameCompanion(memberId: memberId, name: name),
);

/// Shared mutation shape: typed SDK failures become [failureMessage] for
/// the dialog layer WITHOUT dropping the loaded view; unexpected failures
/// are logged with a generic message.
Future<void> _mutate(
Future<CompanionView> Function(String memberId) action,
) async {
final view = state.view;
if (view == null) return;
try {
final updated = await action(view.companion.memberId);
emit(
state.copyWith(
status: CompanionStatus.loaded,
view: updated,
clearFailure: true,
),
);
} on InsufficientDewdropsException catch (e) {
emit(state.copyWith(failureMessage: e.message));
} on CosmeticAlreadyOwnedException catch (e) {
emit(state.copyWith(failureMessage: e.message));
} on CosmeticNotOwnedException catch (e) {
emit(state.copyWith(failureMessage: e.message));
} on UnknownCosmeticException catch (e) {
emit(state.copyWith(failureMessage: e.message));
} on ValidationException catch (e) {
emit(state.copyWith(failureMessage: e.message));
} on Exception catch (e, st) {
log.warning('CompanionCubit mutation failed', e, st);
emit(state.copyWith(failureMessage: 'Something went wrong.'));
}
}
}

Register the repository. In app/lib/outside/repositories/all.dart: add the import import 'companion/companion_repository.dart';, a required this.companionRepository, constructor parameter, a final CompanionRepository companionRepository; field, companionRepository, in getList(), and in createProviders():

RepositoryProvider<CompanionRepository>.value(value: companionRepository),

In app/lib/app/runner.dart (beside the achievementsRepository: argument at ~line 167):

companionRepository: CompanionRepository(
clientProvider: clientProviders.sdkClientProvider,
),

(and the matching import 'package:household_app/outside/repositories/companion/companion_repository.dart'; in that file's import style).

Test seams. In app/test/util/mocks/repositories.dart:

class MockCompanionRepository extends Mock implements CompanionRepository {}

(with import 'package:household_app/outside/repositories/companion/companion_repository.dart';). In app/test/util/mocks/mocked_app.dart (MocksContainer): add companionRepository = MockCompanionRepository(), to the constructor initializer list, a final MockCompanionRepository companionRepository; field, and a default stub in the constructor body so no existing flow test needs to know the companion exists:

// Companion (Tier 1): the ambient layer loads on shell mount. Default to
// null (no session/member) so the layer renders nothing unless a test
// stubs a view.
when(companionRepository.loadForCurrentMember)
.thenAnswer((_) async => null);

In app/test/util/test_app_builder.dart, pass companionRepository: mocks.companionRepository, where the RepositoriesAll is constructed.

  • Step 4: Run test to verify it passes

Run (from app/): fvm flutter test test/blocs/companion_cubit_test.dart Expected: PASS. Then the full app suite: fvm flutter test Expected: PASS, ≥ 553.

  • Step 5: Commit
git add app/lib/outside/repositories/companion/companion_repository.dart app/lib/inside/blocs/companion/state.dart app/lib/inside/blocs/companion/cubit.dart app/lib/outside/repositories/all.dart app/lib/app/runner.dart app/test/util/mocks/repositories.dart app/test/util/mocks/mocked_app.dart app/test/util/test_app_builder.dart app/test/blocs/companion_cubit_test.dart
git commit -m "feat: CompanionRepository + CompanionCubit (achievements-sibling presentation pair)"

Task 9: Design system — dewdrop token, creature + room layers, sheet, "+N" float, goldens

Files:

  • Modify: packages/design_system/lib/src/tokens/color_tokens.dart (new dewdrop token: constructor param, field, dark/light/focus palette values, and the Color.lerp blend at ~line 615)
  • Create: packages/design_system/lib/src/graphics/ds_companion_creature.dart
  • Create: packages/design_system/lib/src/graphics/ds_room_tidiness.dart
  • Create: packages/design_system/lib/src/graphics/ds_companion_scene.dart
  • Create: packages/design_system/lib/src/atoms/ds_dewdrop_float.dart
  • Create: packages/design_system/lib/src/molecules/ds_companion_sheet.dart
  • Modify: packages/design_system/lib/design_system.dart (barrel exports)
  • Test: packages/design_system/test/companion_widget_test.dart
  • Test: packages/design_system/test/golden/companion_golden_test.dart

Interfaces:

  • Consumes: DsTheme.of(context), theme.colors (ColorTokens incl. new dewdrop, existing coin, give, save, glow, ink, inkMuted, inkFaint, surfaceRaised, border, accent), theme.motion.durationOrZero(context, duration) + theme.motion.glide, MediaQuery.disableAnimationsOf(context) — exactly the DsHorizonHouse/DsCelebrationBurst house patterns. DS stays MODEL-AGNOSTIC: zero client_sdk imports; the app maps SDK ids/enums to plain params.

  • Produces (consumed by Task 10):

    • enum DsCompanionHat { none, cap, beret, flowerCrown }
    • DsCompanionCreature({required int stage, Color? tint, DsCompanionHat hat = .none, int reactTick = 0, double size = 88})
    • DsRoomTidiness({required int tier, double height = 48}) (tier 0..2)
    • DsCompanionScene({required int stage, required int messinessTier, Color? tint, DsCompanionHat hat = .none, int reactTick = 0, int? dewdropDelta, VoidCallback? onCreatureTap, double height = 132})
    • DsDewdropFloat({required int amount, VoidCallback? onDone})
    • DsCompanionSheetCosmetic({required String id, required String label, required int cost, required bool owned, required bool equipped})
    • DsCompanionSheet({required int stage, required String stageLabel, required int dewdropBalance, required List<DsCompanionSheetCosmetic> cosmetics, String? name, required String namePlaceholder, Color? tint, DsCompanionHat hat = .none, VoidCallback? onRenameTap, ValueChanged<String>? onBuy, ValueChanged<String>? onEquip})
    • new ColorTokens.dewdrop color (dewdrop "+N" reads visually DISTINCT from the token/coin gold at a glance).
  • Step 1: Write the failing test

packages/design_system/test/companion_widget_test.dart:

import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Widget _host(Widget child, {bool reducedMotion = false}) => MaterialApp(
debugShowCheckedModeBanner: false,
theme: DsTheme.dark.toThemeData(),
home: MediaQuery(
data: MediaQueryData(disableAnimations: reducedMotion),
child: Scaffold(body: Center(child: child)),
),
);

void main() {
test('dewdrop token exists in both palettes and is DISTINCT from the coin '
'gold (the two economies must read separately at a glance)', () {
expect(ColorTokens.dark.dewdrop, isNot(ColorTokens.dark.coin));
expect(ColorTokens.light.dewdrop, isNot(ColorTokens.light.coin));
});

testWidgets('creature settles to a static happy pose under reduced motion '
'(no pending timers, pumpAndSettle completes)', (tester) async {
await tester.pumpWidget(
_host(
const DsCompanionCreature(stage: 2, reactTick: 1),
reducedMotion: true,
),
);
await tester.pumpAndSettle();
expect(find.byType(DsCompanionCreature), findsOneWidget);
});

testWidgets('scene forwards a creature tap (tap-to-focus opens the sheet '
'upstream)', (tester) async {
var tapped = 0;
await tester.pumpWidget(
_host(
DsCompanionScene(
stage: 1,
messinessTier: 0,
onCreatureTap: () => tapped++,
),
reducedMotion: true,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(DsCompanionCreature));
expect(tapped, 1);
});

testWidgets('room tidiness renders nothing at tier 0 and paints at tiers '
'1/2 (gentle, capped)', (tester) async {
await tester.pumpWidget(
_host(const DsRoomTidiness(tier: 0), reducedMotion: true),
);
expect(find.byType(CustomPaint), findsNothing);
await tester.pumpWidget(
_host(const DsRoomTidiness(tier: 2), reducedMotion: true),
);
expect(
find.descendant(
of: find.byType(DsRoomTidiness),
matching: find.byType(CustomPaint),
),
findsOneWidget,
);
});

testWidgets('dewdrop float shows +N and completes without motion under '
'reduced motion', (tester) async {
var done = 0;
await tester.pumpWidget(
_host(
DsDewdropFloat(amount: 1, onDone: () => done++),
reducedMotion: true,
),
);
await tester.pumpAndSettle();
expect(find.text('+1'), findsOneWidget);
expect(done, 1);
});

testWidgets('sheet renders name/stage/balance and fires buy + equip + '
'rename callbacks', (tester) async {
String? bought;
String? equipped;
var renameTaps = 0;
await tester.pumpWidget(
_host(
SingleChildScrollView(
child: DsCompanionSheet(
stage: 2,
stageLabel: 'Sprout',
dewdropBalance: 6,
name: 'Fern',
namePlaceholder: 'Name me',
cosmetics: const [
DsCompanionSheetCosmetic(
id: 'hat_sprout_cap',
label: 'Sprout Cap',
cost: 5,
owned: false,
equipped: false,
),
DsCompanionSheetCosmetic(
id: 'color_moss',
label: 'Moss Green',
cost: 5,
owned: true,
equipped: false,
),
],
onRenameTap: () => renameTaps++,
onBuy: (id) => bought = id,
onEquip: (id) => equipped = id,
),
),
reducedMotion: true,
),
);
await tester.pumpAndSettle();
expect(find.text('Fern'), findsOneWidget);
expect(find.text('Sprout'), findsOneWidget);
expect(find.text('6'), findsOneWidget);
await tester.tap(find.text('Sprout Cap'));
expect(bought, 'hat_sprout_cap');
await tester.tap(find.text('Moss Green'));
expect(equipped, 'color_moss');
await tester.tap(find.byIcon(Icons.edit_outlined));
expect(renameTaps, 1);
});
}
  • Step 2: Run test to verify it fails

Run (from packages/design_system/): fvm flutter test test/companion_widget_test.dart Expected: FAIL — compile error: dewdrop / DsCompanionCreature etc. undefined.

  • Step 3: Write minimal implementation

3a. packages/design_system/lib/src/tokens/color_tokens.dart — add to the constructor parameter list required this.dewdrop, (after required this.coinInk,), the field after coinInk:

/// DEWDROP — the companion's soft currency. A cool dewy aqua, deliberately
/// DISTINCT from the warm token/coin gold ([coin]) so the two economies
/// read as separate at a glance (they are never convertible).
final Color dewdrop;

palette values — in static const ColorTokens dark (after coinInk:):

dewdrop: Color(0xFF8FD9C9),

in static const ColorTokens light:

dewdrop: Color(0xFF167A6E),

in static const ColorTokens focus:

dewdrop: Color(0xFF9FF2E2),

and in the blend/lerp constructor call at ~line 615 (the return ColorTokens(...) that lerps every field):

dewdrop: Color.lerp(dewdrop, other.dewdrop, t)!,

(match the exact lerp expression shape of the surrounding fields — if the method lerps via a helper, use the same helper).

3b. packages/design_system/lib/src/graphics/ds_companion_creature.dart (new file, complete):

import 'dart:math' as math;

import 'package:flutter/material.dart';

import '../theme/theme.dart';

/// Which hat cosmetic the creature wears. Model-agnostic: the app maps SDK
/// cosmetic ids onto these variants; the DS never imports client_sdk.
enum DsCompanionHat { none, cap, beret, flowerCrown }

/// The companion creature — a small sprout being painted in the DS visual
/// language (simple-art-first: a CustomPaint over shared geometry, the same
/// house style as [DsHorizonHouse] / [DsCelebrationBurst]; no Rive rig).
///
/// * [stage] 1..3 grows the body and adds leaves/blossom (behavior-gated
/// upstream — this widget only renders).
/// * [reactTick] increments to play ONE happy bounce (the completion
/// reaction, fired off the same celebration signal as the burst).
/// * **Reduced motion (CRITICAL):** under `MediaQuery.disableAnimations` the
/// idle bob and bounce never tick — the creature holds a STATIC HAPPY POSE
/// via [MotionTokens.durationOrZero]. The companion is never a motion
/// problem.
class DsCompanionCreature extends StatefulWidget {
const DsCompanionCreature({
required this.stage,
this.tint,
this.hat = DsCompanionHat.none,
this.reactTick = 0,
this.size = 88,
super.key,
});

/// Growth stage 1..3 (seedling / sprout / bloom). Clamped defensively.
final int stage;

/// Body tint from an equipped color cosmetic; defaults to the DS green.
final Color? tint;
final DsCompanionHat hat;

/// Increment to play one happy bounce.
final int reactTick;
final double size;

@override
State<DsCompanionCreature> createState() => _DsCompanionCreatureState();
}

class _DsCompanionCreatureState extends State<DsCompanionCreature>
with TickerProviderStateMixin {
static const Duration _idlePeriod = Duration(milliseconds: 2600);
static const Duration _bounceLife = Duration(milliseconds: 600);

late final AnimationController _idle = AnimationController(
vsync: this,
duration: _idlePeriod,
);
late final AnimationController _bounce = AnimationController(
vsync: this,
duration: _bounceLife,
);

@override
void didChangeDependencies() {
super.didChangeDependencies();
_syncIdle();
}

@override
void didUpdateWidget(DsCompanionCreature oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.reactTick != widget.reactTick) {
final duration = DsTheme.of(context)
.motion
.durationOrZero(context, _bounceLife);
_bounce.duration = duration;
if (duration == Duration.zero) {
// Reduced motion: settle instantly on the happy end frame.
_bounce.value = 1;
} else {
_bounce.forward(from: 0);
}
}
}

/// Runs the idle bob only when motion is allowed; otherwise holds the
/// static happy pose (mid-bob frame).
void _syncIdle() {
final reduced = MediaQuery.disableAnimationsOf(context);
if (reduced) {
_idle
..stop()
..value = 0.35;
} else if (!_idle.isAnimating) {
_idle.repeat(reverse: true);
}
}

@override
void dispose() {
_idle.dispose();
_bounce.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
final c = DsTheme.of(context).colors;
final body = widget.tint ?? c.give;
return RepaintBoundary(
child: SizedBox.square(
dimension: widget.size,
child: AnimatedBuilder(
animation: Listenable.merge([_idle, _bounce]),
builder: (context, _) {
// Gentle idle bob (2px) + a single-arc happy hop (10% of size).
final bob = math.sin(_idle.value * math.pi) * 2;
final hop =
math.sin(_bounce.value * math.pi) * widget.size * 0.10;
return CustomPaint(
size: Size.square(widget.size),
painter: _CreaturePainter(
stage: widget.stage.clamp(1, 3),
body: body,
leaf: c.give,
blossom: c.accent,
face: c.ink,
shadow: c.shadow,
hat: widget.hat,
hatColor: c.glow,
lift: bob + hop,
),
);
},
),
),
);
}
}

class _CreaturePainter extends CustomPainter {
const _CreaturePainter({
required this.stage,
required this.body,
required this.leaf,
required this.blossom,
required this.face,
required this.shadow,
required this.hat,
required this.hatColor,
required this.lift,
});

final int stage;
final Color body;
final Color leaf;
final Color blossom;
final Color face;
final Color shadow;
final DsCompanionHat hat;
final Color hatColor;

/// Vertical offset (idle bob + happy hop), in logical px.
final double lift;

@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final groundY = size.height - w * 0.08;
// Grounding shadow stays put while the body lifts — sells the hop.
canvas.drawOval(
Rect.fromCenter(
center: Offset(w / 2, groundY),
width: w * 0.5,
height: w * 0.1,
),
Paint()..color = shadow.withValues(alpha: 0.18),
);

canvas.save();
canvas.translate(0, -lift);

// Body grows with stage: a soft rounded blob.
final bodyRadius = w * (0.22 + 0.04 * stage);
final bodyCenter = Offset(w / 2, groundY - bodyRadius);
canvas.drawCircle(bodyCenter, bodyRadius, Paint()..color = body);

// Sprout leaves on top: 1 leaf at stage 1, 2 at stage 2+.
final stemTop = Offset(bodyCenter.dx, bodyCenter.dy - bodyRadius - w * 0.06);
final leafPaint = Paint()..color = leaf;
canvas.drawLine(
Offset(bodyCenter.dx, bodyCenter.dy - bodyRadius),
stemTop,
Paint()
..color = leaf
..strokeWidth = 2
..strokeCap = StrokeCap.round,
);
canvas.drawOval(
Rect.fromCenter(
center: stemTop.translate(-w * 0.07, -w * 0.02),
width: w * 0.14,
height: w * 0.07,
),
leafPaint,
);
if (stage >= 2) {
canvas.drawOval(
Rect.fromCenter(
center: stemTop.translate(w * 0.07, -w * 0.02),
width: w * 0.14,
height: w * 0.07,
),
leafPaint,
);
}
// Stage 3: a small blossom crowning the sprout.
if (stage >= 3) {
canvas.drawCircle(
stemTop.translate(0, -w * 0.05),
w * 0.05,
Paint()..color = blossom,
);
}

// Happy face: two eyes + smile arc (always happy — forgiving presence).
final eyePaint = Paint()..color = face;
canvas.drawCircle(
bodyCenter.translate(-bodyRadius * 0.35, -bodyRadius * 0.15),
w * 0.025,
eyePaint,
);
canvas.drawCircle(
bodyCenter.translate(bodyRadius * 0.35, -bodyRadius * 0.15),
w * 0.025,
eyePaint,
);
canvas.drawArc(
Rect.fromCenter(
center: bodyCenter.translate(0, bodyRadius * 0.15),
width: bodyRadius * 0.7,
height: bodyRadius * 0.5,
),
0.3,
math.pi - 0.6,
false,
Paint()
..color = face
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round,
);

// Hat cosmetic, perched above the leaves.
final hatCenter = stemTop.translate(0, -w * 0.10);
final hatPaint = Paint()..color = hatColor;
switch (hat) {
case DsCompanionHat.none:
break;
case DsCompanionHat.cap:
canvas.drawArc(
Rect.fromCenter(
center: hatCenter,
width: w * 0.24,
height: w * 0.18,
),
math.pi,
math.pi,
true,
hatPaint,
);
case DsCompanionHat.beret:
canvas.drawOval(
Rect.fromCenter(
center: hatCenter,
width: w * 0.26,
height: w * 0.10,
),
hatPaint,
);
case DsCompanionHat.flowerCrown:
for (var i = -1; i <= 1; i++) {
canvas.drawCircle(
hatCenter.translate(i * w * 0.08, 0),
w * 0.035,
hatPaint,
);
}
}

canvas.restore();
}

@override
bool shouldRepaint(_CreaturePainter old) =>
old.stage != stage ||
old.body != body ||
old.hat != hat ||
old.lift != lift;
}

3c. packages/design_system/lib/src/graphics/ds_room_tidiness.dart (new file, complete):

import 'package:flutter/material.dart';

import '../theme/theme.dart';

/// The gentle, HARD-CAPPED cozy-messiness layer — the ONLY neglect signal,
/// painted on the ENVIRONMENT, never the creature (spec: forgiving presence;
/// the Animal-Crossing-weeds pattern). Deterministic scatter, no animation.
///
/// [tier]: 0 = tidy (renders nothing), 1 = a few leaves, 2 = cozy-dusty
/// (leaves + dust motes). Anything above 2 clamps to 2 — two months never
/// looks worse than one.
class DsRoomTidiness extends StatelessWidget {
const DsRoomTidiness({required this.tier, this.height = 48, super.key});

final int tier;
final double height;

@override
Widget build(BuildContext context) {
final clamped = tier.clamp(0, 2);
if (clamped == 0) return const SizedBox.shrink();
final c = DsTheme.of(context).colors;
return IgnorePointer(
child: SizedBox(
height: height,
width: double.infinity,
child: CustomPaint(
painter: _TidinessPainter(
leaf: c.give,
dust: c.inkFaint,
cozyDusty: clamped >= 2,
),
),
),
);
}
}

class _TidinessPainter extends CustomPainter {
const _TidinessPainter({
required this.leaf,
required this.dust,
required this.cozyDusty,
});

final Color leaf;
final Color dust;
final bool cozyDusty;

/// Deterministic scatter as (x-fraction, y-fraction, rotation) — no
/// randomness so goldens are stable.
static const List<List<double>> _leaves = [
[0.12, 0.75, 0.4],
[0.31, 0.85, -0.6],
[0.55, 0.70, 0.2],
[0.72, 0.88, -0.3],
[0.90, 0.78, 0.7],
];
static const List<List<double>> _motes = [
[0.20, 0.30, 0],
[0.44, 0.20, 0],
[0.63, 0.35, 0],
[0.83, 0.25, 0],
];

@override
void paint(Canvas canvas, Size size) {
final leafPaint = Paint()..color = leaf.withValues(alpha: 0.45);
for (final l in _leaves) {
canvas.save();
canvas.translate(l[0] * size.width, l[1] * size.height);
canvas.rotate(l[2]);
canvas.drawOval(
Rect.fromCenter(center: Offset.zero, width: 10, height: 5),
leafPaint,
);
canvas.restore();
}
if (cozyDusty) {
final dustPaint = Paint()..color = dust.withValues(alpha: 0.35);
for (final m in _motes) {
canvas.drawCircle(
Offset(m[0] * size.width, m[1] * size.height),
1.5,
dustPaint,
);
}
}
}

@override
bool shouldRepaint(_TidinessPainter old) =>
old.leaf != leaf || old.dust != dust || old.cozyDusty != cozyDusty;
}

3d. packages/design_system/lib/src/graphics/ds_companion_scene.dart (new file, complete):

import 'package:flutter/material.dart';

import '../atoms/ds_dewdrop_float.dart';
import 'ds_companion_creature.dart';
import 'ds_room_tidiness.dart';

/// The ambient companion composition layered over the horizon: the
/// room-tidiness scatter behind, the creature in its spot, and the dewdrop
/// "+N" float above on a completion. Mounts in the app's horizon backdrop
/// host (over [DsAppBackdrop]); only the CREATURE is tappable — everything
/// else lets touches pass through.
class DsCompanionScene extends StatelessWidget {
const DsCompanionScene({
required this.stage,
required this.messinessTier,
this.tint,
this.hat = DsCompanionHat.none,
this.reactTick = 0,
this.dewdropDelta,
this.onCreatureTap,
this.height = 132,
super.key,
});

final int stage;

/// 0 tidy / 1 leaves / 2 cozy-dusty (already intensity-gated upstream).
final int messinessTier;
final Color? tint;
final DsCompanionHat hat;
final int reactTick;

/// When non-null, shows a one-shot "+N" dewdrop float over the creature.
final int? dewdropDelta;
final VoidCallback? onCreatureTap;
final double height;

@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
child: Stack(
alignment: Alignment.bottomLeft,
children: <Widget>[
Positioned(
left: 0,
right: 0,
bottom: 0,
child: DsRoomTidiness(tier: messinessTier),
),
Positioned(
left: 16,
bottom: 4,
child: Semantics(
label: 'Your companion',
button: onCreatureTap != null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onCreatureTap,
child: DsCompanionCreature(
stage: stage,
tint: tint,
hat: hat,
reactTick: reactTick,
),
),
),
),
if (dewdropDelta != null)
Positioned(
left: 48,
bottom: 96,
child: DsDewdropFloat(
// Re-key per reaction so each completion replays the float.
key: ValueKey<int>(reactTick),
amount: dewdropDelta!,
),
),
],
),
);
}
}

3e. packages/design_system/lib/src/atoms/ds_dewdrop_float.dart (new file, complete):

import 'package:flutter/material.dart';

import '../theme/theme.dart';

/// The dewdrop "+N" float — VISUALLY DISTINCT from the token "+N": dewdrop
/// aqua ([ColorTokens.dewdrop]) with a droplet glyph, never the coin gold.
/// One-shot: rises ~24px and fades; under reduced motion it renders the
/// settled end state instantly ([MotionTokens.durationOrZero]) and calls
/// [onDone] without motion.
class DsDewdropFloat extends StatefulWidget {
const DsDewdropFloat({required this.amount, this.onDone, super.key});

final int amount;
final VoidCallback? onDone;

@override
State<DsDewdropFloat> createState() => _DsDewdropFloatState();
}

class _DsDewdropFloatState extends State<DsDewdropFloat>
with SingleTickerProviderStateMixin {
static const Duration _life = Duration(milliseconds: 900);
AnimationController? _controller;

@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_controller != null) return;
final motion = DsTheme.of(context).motion;
final duration = motion.durationOrZero(context, _life);
final controller = _controller = AnimationController(
vsync: this,
duration: duration,
);
controller.addStatusListener((status) {
if (status == AnimationStatus.completed) widget.onDone?.call();
});
if (duration == Duration.zero) {
controller.value = 1;
// Zero-duration controllers do not fire a completed transition from
// a direct value set; notify explicitly.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onDone?.call();
});
} else {
controller.forward();
}
}

@override
void dispose() {
_controller?.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
final theme = DsTheme.of(context);
final color = theme.colors.dewdrop;
final controller = _controller;
if (controller == null) return const SizedBox.shrink();
return AnimatedBuilder(
animation: controller,
builder: (context, child) {
final t = Curves.easeOutCubic.transform(controller.value);
// Fade over the back half so the float dissolves as it crests.
final opacity =
controller.value < 0.5 ? 1.0 : (2 - controller.value * 2);
return Transform.translate(
offset: Offset(0, -24 * t),
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: child,
),
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
CustomPaint(
size: const Size(10, 12),
painter: _DropletPainter(color: color),
),
const SizedBox(width: 4),
Text(
'+${widget.amount}',
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: 15,
),
),
],
),
);
}
}

class _DropletPainter extends CustomPainter {
const _DropletPainter({required this.color});

final Color color;

@override
void paint(Canvas canvas, Size size) {
final path = Path()
..moveTo(size.width / 2, 0)
..quadraticBezierTo(size.width, size.height * 0.55, size.width / 2,
size.height)
..quadraticBezierTo(0, size.height * 0.55, size.width / 2, 0)
..close();
canvas.drawPath(path, Paint()..color = color);
}

@override
bool shouldRepaint(_DropletPainter old) => old.color != color;
}

3f. packages/design_system/lib/src/molecules/ds_companion_sheet.dart (new file, complete):

import 'package:flutter/material.dart';

import '../graphics/ds_companion_creature.dart';
import '../theme/theme.dart';

/// One cosmetics-tray row of the companion sheet — plain data, no SDK types
/// (the app maps its catalog + owned/equipped sets onto this).
class DsCompanionSheetCosmetic {
const DsCompanionSheetCosmetic({
required this.id,
required this.label,
required this.cost,
required this.owned,
required this.equipped,
});

final String id;
final String label;
final int cost;
final bool owned;
final bool equipped;
}

/// The tap-to-focus companion sheet (spec §6): creature large, name with a
/// rename affordance, growth-stage label, dewdrop balance, and the small
/// cosmetics tray. Model-agnostic and string-agnostic — all copy passed in.
///
/// Tapping a tray row fires [onBuy] when not owned, [onEquip] when owned and
/// not equipped, and nothing when already equipped.
class DsCompanionSheet extends StatelessWidget {
const DsCompanionSheet({
required this.stage,
required this.stageLabel,
required this.dewdropBalance,
required this.cosmetics,
required this.namePlaceholder,
this.name,
this.tint,
this.hat = DsCompanionHat.none,
this.onRenameTap,
this.onBuy,
this.onEquip,
super.key,
});

final int stage;
final String stageLabel;
final int dewdropBalance;
final List<DsCompanionSheetCosmetic> cosmetics;
final String? name;
final String namePlaceholder;
final Color? tint;
final DsCompanionHat hat;
final VoidCallback? onRenameTap;
final ValueChanged<String>? onBuy;
final ValueChanged<String>? onEquip;

@override
Widget build(BuildContext context) {
final c = DsTheme.of(context).colors;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
DsCompanionCreature(stage: stage, tint: tint, hat: hat, size: 140),
const SizedBox(height: 12),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
name ?? namePlaceholder,
style: TextStyle(
color: name == null ? c.inkMuted : c.ink,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 6),
Semantics(
label: 'Rename companion',
button: true,
child: GestureDetector(
onTap: onRenameTap,
child: Icon(Icons.edit_outlined, size: 18, color: c.inkMuted),
),
),
],
),
const SizedBox(height: 4),
Text(stageLabel, style: TextStyle(color: c.inkMuted, fontSize: 14)),
const SizedBox(height: 12),
Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(Icons.water_drop, size: 16, color: c.dewdrop),
const SizedBox(width: 4),
Text(
'$dewdropBalance',
style: TextStyle(
color: c.dewdrop,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: <Widget>[
for (final item in cosmetics)
_CosmeticTile(
item: item,
onTap: item.equipped
? null
: item.owned
? () => onEquip?.call(item.id)
: () => onBuy?.call(item.id),
),
],
),
],
),
);
}
}

class _CosmeticTile extends StatelessWidget {
const _CosmeticTile({required this.item, required this.onTap});

final DsCompanionSheetCosmetic item;
final VoidCallback? onTap;

@override
Widget build(BuildContext context) {
final c = DsTheme.of(context).colors;
return Semantics(
label: item.label,
button: onTap != null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: item.equipped ? c.tealSoft : c.surfaceRaised,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: item.equipped ? c.teal : c.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
item.label,
style: TextStyle(color: c.ink, fontSize: 14),
),
const SizedBox(width: 6),
if (item.equipped)
Icon(Icons.check, size: 14, color: c.teal)
else if (item.owned)
Icon(Icons.checkroom, size: 14, color: c.inkMuted)
else ...<Widget>[
Icon(Icons.water_drop, size: 12, color: c.dewdrop),
const SizedBox(width: 2),
Text(
'${item.cost}',
style: TextStyle(color: c.dewdrop, fontSize: 13),
),
],
],
),
),
),
);
}
}

3g. packages/design_system/lib/design_system.dart — add exports beside the existing graphics/atoms/molecules export lines:

export 'src/atoms/ds_dewdrop_float.dart';
export 'src/graphics/ds_companion_creature.dart';
export 'src/graphics/ds_companion_scene.dart';
export 'src/graphics/ds_room_tidiness.dart';
export 'src/molecules/ds_companion_sheet.dart';

3h. packages/design_system/test/golden/companion_golden_test.dart (new file, complete — mirrors atmosphere_horizon_golden_test.dart's day/night harness):

@Tags(<String>['golden'])
library;

import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';

/// Golden snapshots for the companion: the ambient scene at the three
/// cozy-messiness tiers, the focused sheet, and the reduced-motion static
/// pose. Every snapshot disables animations so the painters are
/// deterministic — which IS the reduced-motion pose: the creature's settled
/// happy frame. Tagged `golden` like every other DS golden.
void main() {
const variants = <String, DsTheme>{
'dark': DsTheme.dark,
'light': DsTheme.light,
};

Future<void> dayNightGolden(
WidgetTester tester, {
required String name,
required Widget child,
required Size surfaceSize,
}) async {
for (final entry in variants.entries) {
final theme = entry.value;
await tester.pumpWidgetBuilder(
Center(child: child),
wrapper: (inner) => MaterialApp(
debugShowCheckedModeBanner: false,
theme: theme.toThemeData(),
home: MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: Scaffold(backgroundColor: theme.colors.surface, body: inner),
),
),
surfaceSize: surfaceSize,
);
await tester.pumpAndSettle();
await screenMatchesGolden(tester, '$name.${entry.key}');
}
}

group('DsCompanionScene golden', () {
for (final (tier, label) in [(0, 'tidy'), (1, 'leaves'), (2, 'cozy_dusty')]) {
testGoldens('ambient scene — $label', (tester) async {
await dayNightGolden(
tester,
name: 'ds_companion_scene_$label',
surfaceSize: const Size(360, 180),
child: SizedBox(
width: 340,
height: 160,
child: DsCompanionScene(stage: 2, messinessTier: tier),
),
);
});
}

testGoldens('reduced-motion static happy pose (stage 3, hat + tint)',
(tester) async {
await dayNightGolden(
tester,
name: 'ds_companion_reduced_motion',
surfaceSize: const Size(200, 200),
child: const DsCompanionCreature(
stage: 3,
hat: DsCompanionHat.flowerCrown,
reactTick: 1,
size: 140,
),
);
});
});

group('DsCompanionSheet golden', () {
testGoldens('focused sheet', (tester) async {
await dayNightGolden(
tester,
name: 'ds_companion_sheet',
surfaceSize: const Size(400, 560),
child: const SizedBox(
width: 380,
child: DsCompanionSheet(
stage: 2,
stageLabel: 'Sprout',
dewdropBalance: 6,
name: 'Fern',
namePlaceholder: 'Name me',
cosmetics: [
DsCompanionSheetCosmetic(
id: 'hat_sprout_cap',
label: 'Sprout Cap',
cost: 5,
owned: true,
equipped: true,
),
DsCompanionSheetCosmetic(
id: 'hat_acorn',
label: 'Acorn Beret',
cost: 8,
owned: true,
equipped: false,
),
DsCompanionSheetCosmetic(
id: 'hat_flower',
label: 'Flower Crown',
cost: 12,
owned: false,
equipped: false,
),
DsCompanionSheetCosmetic(
id: 'color_moss',
label: 'Moss Green',
cost: 5,
owned: false,
equipped: false,
),
],
),
),
);
});
});
}
  • Step 4: Run test to verify it passes

Run (from packages/design_system/): fvm flutter test test/companion_widget_test.dart Expected: PASS. Generate golden baselines: fvm flutter test --tags golden --update-goldens test/golden/companion_golden_test.dart Verify stable: fvm flutter test --tags golden test/golden/companion_golden_test.dart Expected: PASS. Full DS suite: fvm flutter test Expected: PASS (goldens excluded by tag as house-configured).

  • Step 5: Commit
git add packages/design_system/lib/src/tokens/color_tokens.dart packages/design_system/lib/src/graphics/ds_companion_creature.dart packages/design_system/lib/src/graphics/ds_room_tidiness.dart packages/design_system/lib/src/graphics/ds_companion_scene.dart packages/design_system/lib/src/atoms/ds_dewdrop_float.dart packages/design_system/lib/src/molecules/ds_companion_sheet.dart packages/design_system/lib/design_system.dart packages/design_system/test/companion_widget_test.dart packages/design_system/test/golden/companion_golden_test.dart packages/design_system/test/golden/goldens
git commit -m "feat: DS companion — dewdrop token, creature/room layers, sheet, +N float, goldens"

(If this DS repo stores goldens beside the test in a different directory, git add the generated .png files at their actual generated path — never -A.)


Task 10: Wiring — mount the layers, tap-to-open sheet, celebration reaction + tests

Files:

  • Create: app/lib/inside/routes/authenticated/shell/companion_layer.dart
  • Modify: app/lib/inside/routes/authenticated/shell/page.dart (provide CompanionCubit, mount the layer over the DsAdaptiveScaffold — the backdrop host at ~line 254–273)
  • Modify: app/lib/inside/celebration/celebration_host.dart (fire the companion reaction off the SAME diff)
  • Test: app/test/widgets/companion_layer_test.dart
  • Test: app/test/flows/companion_test.dart

Interfaces:

  • Consumes: CompanionCubit/CompanionState (Task 8), DS widgets (Task 9), resolveCelebrationRequest(previous, current) + TodayCelebrationHost._onState (the existing ChoreDoneState.submitting → submitted|done diff in app/lib/inside/celebration/), kDsPhoneNavBarHeight (DS), the flow harness (MocksContainer, testAppBuilder, createFlowConfig, warpToHome per app/test/README.md).

  • Produces: CompanionLayer widget (self-positioning ambient layer + sheet opener), the celebration hook _notifyCompanion.

  • Step 1: Write the failing test

app/test/widgets/companion_layer_test.dart (interaction coverage: tap-to-open sheet, buy decrements, rename persists — full WidgetTester control):

import 'package:client_sdk/client_sdk.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:household_app/inside/blocs/companion/cubit.dart';
import 'package:household_app/inside/routes/authenticated/shell/companion_layer.dart';
import 'package:household_app/outside/repositories/companion/companion_repository.dart';
import 'package:mocktail/mocktail.dart';

class _MockCompanionRepository extends Mock implements CompanionRepository {}

CompanionView _view({
int balance = 6,
int spent = 0,
Set<String> owned = const {},
List<String> equipped = const [],
String? name,
}) {
return CompanionView(
companion: Companion(
memberId: 'sam',
householdId: 'h1',
species: kCompanionSpecies,
name: name,
equipped: equipped,
),
lifetimeCompletions: balance + spent,
dewdropsEarned: balance + spent,
dewdropsSpent: spent,
dewdropsBalance: balance,
stage: CompanionGrowthStage.seedling,
ownedCosmeticIds: owned,
messiness: CompanionMessinessTier.tidy,
);
}

void main() {
late _MockCompanionRepository repo;

setUp(() {
repo = _MockCompanionRepository();
});

Widget host() => MaterialApp(
debugShowCheckedModeBanner: false,
theme: DsTheme.dark.toThemeData(),
home: MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: BlocProvider<CompanionCubit>(
create: (_) => CompanionCubit(companionRepository: repo)..load(),
child: const Scaffold(body: CompanionLayer()),
),
),
);

testWidgets('renders nothing without a view; renders the scene with one',
(tester) async {
when(repo.loadForCurrentMember).thenAnswer((_) async => null);
await tester.pumpWidget(host());
await tester.pumpAndSettle();
expect(find.byType(DsCompanionScene), findsNothing);

when(repo.loadForCurrentMember).thenAnswer((_) async => _view());
await tester.pumpWidget(host());
await tester.pumpAndSettle();
expect(find.byType(DsCompanionScene), findsOneWidget);
});

testWidgets('tap creature -> sheet opens; buying decrements the balance '
'and marks owned/equippable', (tester) async {
when(repo.loadForCurrentMember).thenAnswer((_) async => _view());
when(
() => repo.purchaseCosmetic(
memberId: 'sam',
cosmeticId: 'hat_sprout_cap',
),
).thenAnswer(
(_) async => _view(balance: 1, spent: 5, owned: {'hat_sprout_cap'}),
);
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await tester.tap(find.byType(DsCompanionCreature));
await tester.pumpAndSettle();
expect(find.byType(DsCompanionSheet), findsOneWidget);
expect(find.text('6'), findsOneWidget);
await tester.tap(find.text('Sprout Cap'));
await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget, reason: 'balance decremented');
verify(
() => repo.purchaseCosmetic(
memberId: 'sam',
cosmeticId: 'hat_sprout_cap',
),
).called(1);
});

testWidgets('rename dialog persists via the cubit', (tester) async {
when(repo.loadForCurrentMember).thenAnswer((_) async => _view());
when(
() => repo.renameCompanion(memberId: 'sam', name: 'Fern'),
).thenAnswer((_) async => _view(name: 'Fern'));
await tester.pumpWidget(host());
await tester.pumpAndSettle();
await tester.tap(find.byType(DsCompanionCreature));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.edit_outlined));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'Fern');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Fern'), findsOneWidget);
verify(() => repo.renameCompanion(memberId: 'sam', name: 'Fern'))
.called(1);
});
}

app/test/flows/companion_test.dart (flow coverage: the ambient creature lives on the home backdrop — screenshots in all theme trips; the README harness shape):

import 'package:client_sdk/client_sdk.dart';
import 'package:design_system/design_system.dart';
import 'package:flow_test/flow_test.dart';
import 'package:flutter_test/flutter_test.dart' hide expect;
import 'package:mocktail/mocktail.dart';

import '../util/flow_config.dart';
import '../util/mocks/mocked_app.dart';
import '../util/warps/to_home.dart';

CompanionView _view({int balance = 3}) => CompanionView(
companion: const Companion(
memberId: 'sam',
householdId: 'h1',
species: kCompanionSpecies,
name: 'Fern',
),
lifetimeCompletions: balance,
dewdropsEarned: balance,
dewdropsSpent: 0,
dewdropsBalance: balance,
stage: CompanionGrowthStage.seedling,
ownedCosmeticIds: const {},
messiness: CompanionMessinessTier.tidy,
);

void main() {
setUpAll(registerClientSdkFallbacks);

final baseDescriptions = [
FTDescription(
descriptionType: 'EPIC',
directoryName: 'engagement',
description: 'Engagement',
),
FTDescription(
descriptionType: 'STORY',
directoryName: 'companion',
atScreenshotsLevel: true,
description:
'As a kid, my companion lives ambiently in the horizon scene I '
'already see, and reacts as I finish chores.',
),
];

flowTest<MocksContainer>(
'AC: the companion is ambient in the home horizon scene',
config: createFlowConfig(
arrangeBeforeWarp: (mocks) {
when(mocks.companionRepository.loadForCurrentMember)
.thenAnswer((_) async => _view());
},
),
descriptions: [
...baseDescriptions,
FTDescription(
descriptionType: 'AC',
directoryName: 'ambient',
description: 'Home shows the creature layered into the horizon.',
),
],
test: (tester) async {
await tester.setUp(warp: warpToHome);
await tester.screenshot(
description: 'creature ambient on the home backdrop',
expectations: (e) => e.expect(
find.byType(DsCompanionScene),
findsOneWidget,
reason: 'the companion lives ambiently in the horizon scene '
'(no new tab)',
),
);
},
);
}

NOTE: if createFlowConfig does not expose an arrangeBeforeWarp hook, use the raw README shape instead — build one shared final mocks = MocksContainer();, pass FTConfig(mockedApps: [FTMockedApp(appBuilder: () { final w = testAppBuilder(mocks); when(mocks.companionRepository.loadForCurrentMember).thenAnswer((_) async => _view()); return w; }, mocks: mocks, events: [])]) — stubbing AFTER testAppBuilder exactly as app/test/README.md documents.

  • Step 2: Run test to verify it fails

Run (from app/): fvm flutter test test/widgets/companion_layer_test.dart Expected: FAIL — compile error: companion_layer.dart does not exist.

  • Step 3: Write minimal implementation

app/lib/inside/routes/authenticated/shell/companion_layer.dart (new file, complete):

import 'package:client_sdk/client_sdk.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../../../blocs/companion/cubit.dart';

/// Maps SDK cosmetic ids onto the DS's model-agnostic hat variants.
DsCompanionHat companionHatFor(List<String> equipped) {
if (equipped.contains('hat_flower')) return DsCompanionHat.flowerCrown;
if (equipped.contains('hat_acorn')) return DsCompanionHat.beret;
if (equipped.contains('hat_sprout_cap')) return DsCompanionHat.cap;
return DsCompanionHat.none;
}

/// Maps SDK color-cosmetic ids onto DS token tints.
Color? companionTintFor(BuildContext context, List<String> equipped) {
final c = DsTheme.of(context).colors;
if (equipped.contains('color_ember')) return c.glow;
if (equipped.contains('color_dusk')) return c.save;
if (equipped.contains('color_moss')) return c.give;
return null;
}

String companionStageLabel(CompanionGrowthStage stage) => switch (stage) {
CompanionGrowthStage.seedling => 'Seedling',
CompanionGrowthStage.sprout => 'Sprout',
CompanionGrowthStage.bloom => 'Bloom',
};

/// The ambient companion layer mounted over the shell's horizon backdrop.
/// Renders nothing until a view is loaded; only the creature itself is
/// tappable (tap-to-focus opens [DsCompanionSheet] — no new tab). The "+N"
/// dewdrop float replays on every [CompanionState.reactionTick].
class CompanionLayer extends StatefulWidget {
const CompanionLayer({super.key});

@override
State<CompanionLayer> createState() => _CompanionLayerState();
}

class _CompanionLayerState extends State<CompanionLayer> {
int _lastShownTick = 0;

@override
Widget build(BuildContext context) {
return BlocBuilder<CompanionCubit, CompanionState>(
builder: (context, state) {
final view = state.view;
if (view == null) return const SizedBox.shrink();
final showFloat = state.reactionTick > _lastShownTick;
final bottomInset =
kDsPhoneNavBarHeight + MediaQuery.viewPaddingOf(context).bottom;
return Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: EdgeInsets.only(left: 4, bottom: bottomInset),
child: DsCompanionScene(
stage: view.stage.index + 1,
messinessTier: view.messiness.index,
tint: companionTintFor(context, view.companion.equipped),
hat: companionHatFor(view.companion.equipped),
reactTick: state.reactionTick,
dewdropDelta: showFloat ? kDewdropsPerCompletion : null,
onCreatureTap: () => _openSheet(context),
),
),
);
},
);
}

void _openSheet(BuildContext context) {
// Consume the float so re-opening the sheet does not replay it.
_lastShownTick = context.read<CompanionCubit>().state.reactionTick;
final cubit = context.read<CompanionCubit>();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) => BlocProvider.value(
value: cubit,
child: BlocBuilder<CompanionCubit, CompanionState>(
builder: (context, state) {
final view = state.view;
if (view == null) return const SizedBox.shrink();
return SafeArea(
child: SingleChildScrollView(
child: DsCompanionSheet(
stage: view.stage.index + 1,
stageLabel: companionStageLabel(view.stage),
dewdropBalance: view.dewdropsBalance,
name: view.companion.name,
namePlaceholder: 'Name me',
tint: companionTintFor(context, view.companion.equipped),
hat: companionHatFor(view.companion.equipped),
cosmetics: [
for (final cosmetic in kCompanionCosmetics)
DsCompanionSheetCosmetic(
id: cosmetic.id,
label: cosmetic.displayName,
cost: cosmetic.dewdropsCost,
owned: view.ownedCosmeticIds.contains(cosmetic.id),
equipped:
view.companion.equipped.contains(cosmetic.id),
),
],
onRenameTap: () => _showRenameDialog(context, cubit),
onBuy: cubit.purchase,
onEquip: cubit.equip,
),
),
);
},
),
),
);
}

void _showRenameDialog(BuildContext context, CompanionCubit cubit) {
final controller = TextEditingController(
text: cubit.state.view?.companion.name,
);
showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Name your companion'),
content: TextField(
controller: controller,
autofocus: true,
maxLength: kCompanionNameMaxLength,
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
cubit.rename(controller.text);
Navigator.of(dialogContext).pop();
},
child: const Text('Save'),
),
],
),
);
}
}

Modify app/lib/inside/routes/authenticated/shell/page.dart: add the imports

import '../../../blocs/companion/cubit.dart';
import '../../../../outside/repositories/companion/companion_repository.dart';
import 'companion_layer.dart';

then (1) wrap the shell's built subtree in the provider, and (2) mount the layer ABOVE the DsAdaptiveScaffold (a sibling in a Stack, NOT inside the background: slot — the background sits behind the transparent page body, which would swallow the creature's taps). The existing DsAdaptiveScaffold(...) expression returned by the AutoTabsRouter builder (the one whose background: is the ValueListenableBuilder/DsAppBackdrop at ~line 266) becomes:

BlocProvider<CompanionCubit>(
create: (context) => CompanionCubit(
companionRepository: context.read<CompanionRepository>(),
)..load(),
child: Stack(
children: <Widget>[
/* the existing DsAdaptiveScaffold(...) expression, unchanged */,
const CompanionLayer(),
],
),
)

Modify app/lib/inside/celebration/celebration_host.dart — the companion reacts to the SAME ChoreDoneState diff, no new trigger plumbing. Add the imports

import 'package:provider/provider.dart' show ProviderNotFoundException;

import '../blocs/companion/cubit.dart';

and in _TodayCelebrationHostState._onState, after if (request == null) return; and before _celebrate(context, request, state);:

_notifyCompanion(context, request.memberId);

with the new method on the state class:

/// Companion reaction off the SAME celebration diff (spec §6): the cubit
/// bumps its reactionTick (happy bounce + "+N" float) and refreshes the
/// dewdrop balance. The cubit is provided by the shell; a bare host
/// (unit-pumped, non-shell) has none — skip silently rather than couple
/// the celebration host to the companion.
void _notifyCompanion(BuildContext context, String memberId) {
try {
context.read<CompanionCubit>().reactToCompletion(memberId);
} on ProviderNotFoundException {
// No companion layer mounted — nothing to do.
}
}
  • Step 4: Run test to verify it passes

Run (from app/): fvm flutter test test/widgets/companion_layer_test.dart test/flows/companion_test.dart Expected: PASS — layer renders/gates, sheet buy/rename interactions verified, flow screenshot captures the ambient scene. Then the full app suite: fvm flutter test Expected: PASS, ≥ 553 + new tests (existing celebration flow tests still green — the hook is a silent no-op where no cubit is provided).

  • Step 5: Commit
git add app/lib/inside/routes/authenticated/shell/companion_layer.dart app/lib/inside/routes/authenticated/shell/page.dart app/lib/inside/celebration/celebration_host.dart app/test/widgets/companion_layer_test.dart app/test/flows/companion_test.dart
git commit -m "feat: mount companion in the horizon backdrop — tap-to-focus sheet + celebration reaction"

Task 11: Two-identity cloud smoke (RLS proof, self-skipping)

Files:

  • Create: packages/client_sdk/test/cloud/companion_rls_live_test.dart

Interfaces:

  • Consumes: the Task-1 schema live on the project (the controller applies the migration BEFORE this task is run); the accept_invite_live_test.dart self-skipping two-identity harness (String.fromEnvironment('SUPABASE_URL'), _ensureSignedIn, per-run _runId rows); supabase package SupabaseClient.

  • Produces: the live RLS proof: child mutates own companion; child cannot read/write a sibling's; parent household read OK; zero-floor trigger + append-only enforced server-side.

  • Step 1: Write the (live) test

packages/client_sdk/test/cloud/companion_rls_live_test.dart (complete — mirrors accept_invite_live_test.dart's structure; the request.jwt.claims-style proof is executed by driving TWO real signed-in identities under the anon publishable key, since the headless key cannot attach a JWT directly):

@Tags(['live'])
library;

/// LOAD-BEARING two-auth-identity companion RLS proof (spec §4.4, §7).
///
/// The in-memory suites pass with ONE identity and NO RLS, so only this live
/// test can observe: a child mutating its OWN companion succeeding, a child
/// being unable to even READ a sibling's companion, a parent's household
/// read working, the self-only insert policy rejecting a cross-member write,
/// the dewdrop zero-floor trigger, and append-only (no UPDATE path).
///
/// Without `SUPABASE_URL` it registers a single skipped placeholder and
/// exits 0, so the offline suite stays green.

import 'package:flutter_test/flutter_test.dart';
import 'package:supabase/supabase.dart';

const _url = String.fromEnvironment('SUPABASE_URL');
const _anonKey = String.fromEnvironment('SUPABASE_ANON_KEY');
const _runId = String.fromEnvironment('SMOKE_RUN_ID', defaultValue: 'devsmoke');

Future<void> _ensureSignedIn(SupabaseClient c, String email, String pw) async {
try {
await c.auth.signInWithPassword(email: email, password: pw);
} on AuthException {
await c.auth.signUp(email: email, password: pw);
await c.auth.signInWithPassword(email: email, password: pw);
}
}

void main() {
if (_url.isEmpty) {
test('companion RLS live — skipped (no SUPABASE_URL)', () {},
skip: 'pass --dart-define-from-file=app/config/supabase.local.json');
return;
}

late SupabaseClient a; // parent, owner of household H
late SupabaseClient b; // child 1 (linked auth account)
late SupabaseClient c; // child 2 — the sibling
final aEmail = '[email protected]';
final bEmail = '[email protected]';
final cEmail = '[email protected]';
const pw = 'Test-passw0rd!';
late String householdId;
late String bMemberId;
late String cMemberId;

setUpAll(() async {
a = SupabaseClient(_url, _anonKey);
b = SupabaseClient(_url, _anonKey);
c = SupabaseClient(_url, _anonKey);
await _ensureSignedIn(a, aEmail, pw);
await _ensureSignedIn(b, bEmail, pw);
await _ensureSignedIn(c, cEmail, pw);

// A bootstraps a household + self as first parent (RLS bootstrap branch),
// then two ACTIVE, consent-granted child rows linked to B's and C's auth
// accounts (a parent may insert household members).
final h = await a.from('households').insert({
'name': 'Companion Live $_runId',
}).select().single();
householdId = h['id'] as String;
await a.from('household_members').insert({
'household_id': householdId,
'display_name': 'A',
'kind': 'parent',
'roles': ['admin'],
'status': 'active',
'auth_user_id': a.auth.currentUser!.id,
'owner': true,
});
final bRow = await a.from('household_members').insert({
'household_id': householdId,
'display_name': 'B',
'kind': 'child',
'status': 'active',
'consent_state': 'granted',
'auth_user_id': b.auth.currentUser!.id,
}).select().single();
bMemberId = bRow['id'] as String;
final cRow = await a.from('household_members').insert({
'household_id': householdId,
'display_name': 'C',
'kind': 'child',
'status': 'active',
'consent_state': 'granted',
'auth_user_id': c.auth.currentUser!.id,
}).select().single();
cMemberId = cRow['id'] as String;
});

test('child B creates + renames its OWN companion (self policies pass)',
() async {
await b.from('member_companion').insert({
'member_id': bMemberId,
'household_id': householdId,
'species': 'sprout',
});
await b
.from('member_companion')
.update({'name': 'Fern'}).eq('member_id', bMemberId);
final rows =
await b.from('member_companion').select().eq('member_id', bMemberId);
expect(rows, hasLength(1));
expect(rows.single['name'], 'Fern');
});

test('child B CANNOT read the sibling companion (RLS hides the row)',
() async {
// C creates its own first (self policy) so a row exists to hide.
await c.from('member_companion').insert({
'member_id': cMemberId,
'household_id': householdId,
'species': 'sprout',
});
final visible =
await b.from('member_companion').select().eq('member_id', cMemberId);
expect(visible, isEmpty,
reason: 'a child sees ONLY its own companion row');
});

test('child B CANNOT write the sibling companion (self-only with check)',
() async {
await expectLater(
b
.from('member_companion')
.update({'name': 'hijack'}).eq('member_id', cMemberId),
completes,
);
// RLS-filtered updates affect 0 rows silently — prove nothing changed
// via C's own read.
final rows =
await c.from('member_companion').select().eq('member_id', cMemberId);
expect(rows.single['name'], isNull,
reason: 'the sibling row is untouched');
// A cross-member LEDGER insert is rejected outright (with check).
await expectLater(
b.from('companion_ledger').insert({
'household_id': householdId,
'member_id': cMemberId,
'cosmetic_id': 'hat_sprout_cap',
'dewdrops_cost': 5,
}),
throwsA(isA<PostgrestException>()),
);
});

test('parent A can READ both kids companions within the household',
() async {
final rows = await a
.from('member_companion')
.select()
.eq('household_id', householdId);
expect(rows.length, greaterThanOrEqualTo(2));
});

test('zero-floor trigger: spending with zero completions is rejected '
'server-side (check_violation twin)', () async {
await expectLater(
b.from('companion_ledger').insert({
'household_id': householdId,
'member_id': bMemberId,
'cosmetic_id': 'hat_sprout_cap',
'dewdrops_cost': 5,
}),
throwsA(isA<PostgrestException>()),
reason: 'earned 0 (no chore_completions) so any spend violates the '
'companion zero floor',
);
});
}
  • Step 2: Run OFFLINE to verify it self-skips

Run (from packages/client_sdk/): fvm flutter test test/cloud/companion_rls_live_test.dart Expected: 1 skipped test, exit 0 (no SUPABASE_URL define — the offline suite stays green).

  • Step 3: Run LIVE (after the controller has applied the Task-1 migration)

Run (from packages/client_sdk/): fvm flutter test test/cloud/companion_rls_live_test.dart --dart-define-from-file=../../app/config/supabase.local.json Expected: PASS — all five live assertions green against the real project (anon publishable key only; both gates proven).

  • Step 4: Commit
git add packages/client_sdk/test/cloud/companion_rls_live_test.dart
git commit -m "test: two-identity companion RLS live smoke (self-skipping)"

Task 12: Final verification — graphify + suite baselines

Files:

  • Modify: none (verification only; graphify-out/ is regenerated but NEVER staged)

  • Step 1: Full SDK suite

Run (from packages/client_sdk/): fvm flutter test Expected: PASS with total ≥ 1049 (baseline) + the new companion tests (model/port/service/facade/local/cloud/cached ≈ 40+). Record the new total.

  • Step 2: Full app suite

Run (from app/): fvm flutter test Expected: PASS with total ≥ 553 (baseline) + the new cubit/widget/flow tests. Record the new total.

  • Step 3: DS suite + goldens

Run (from packages/design_system/): fvm flutter test Expected: PASS. Run (from packages/design_system/): fvm flutter test --tags golden test/golden/companion_golden_test.dart Expected: PASS (stable against committed baselines).

  • Step 4: Update the knowledge graph

Run (from the repo root): graphify update . Expected: graph refreshed (AST-only). Do NOT stage graphify-out/.

  • Step 5: Verify nothing forbidden is staged, then final commit if any stragglers remain

Run: git status --short Expected: no unstaged source changes; graphify-out/, .superpowers/, .claude/ untouched/unstaged. If a straggler source file exists, git add <that exact file> and git commit -m "chore: companion creature — final wiring stragglers".


Spec coverage map (self-review)

Spec sectionTask(s)
§4.2 schema (two tables, append-only, separate ledger)1, 5, 6
§4.3 earn from ChoreCompletion (A3/A4 guard)1 (trigger), 4 (service + test)
§4.4 dual gate + RLS + two-identity proof1, 4, 11
§5 domain rules (earn/growth/purchase/equip/rename/messiness)2 (consts/catalog/exceptions), 4
§6 rendering, ambient home, tap-to-focus, "+N" distinct, reduced motion9, 10
§7 SDK unit tests / flow / smoke / goldens / baselines4, 6, 9, 10, 11, 12
§8 file mapall
§9 global constraintsheader + every task
§10 sequencing (Tiers 2/3 out)scope of this plan (nothing speculative added)