Skip to main content

SP3 — Cloud Data Adapter 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.

Review gates (ECC specialists): route each task's review through flutter-reviewer (Dart); add database-reviewer for any task touching PostgREST queries / RLS reliance / the migration (P2, P5, P6); add security-reviewer for the migration + error-mapping tasks (P3, P5) and the live-smoke task (P6).

Goal: Build a Supabase PostgREST StoragePort so a subscribed household reads/writes its data through the cloud under RLS, with a client-side free→paid upload migration, while free households stay on offline Drift.

Architecture: A new SupabaseStorageAdapter implements StoragePort maps each port method to a PostgREST call on the same SupabaseClient already built for auth (SP2). createClient swaps the CachedStorageAdapter's durable from Drift to the cloud adapter when ClientConfig.dataMode == DataMode.cloud. A CloudMigrationService uploads a local household to the cloud preserving UUIDs, idempotently, then the app flips dataMode.

Tech Stack: Dart 3.9 / Flutter SDK (FVM: fvm dart / fvm flutter). package:supabase ^2.10.6 (already a dependency — pure-Dart GoTrue + PostgREST). Drift for the local tier (unchanged). Tests: flutter_test + a hand-written fake PostgREST.

Global Constraints

  • No service-role key in the app — the adapter uses only the publishable/anon key; RLS is the runtime guard. (Verbatim user instruction: "we need zero trust so those that have access to a house can access household records"; service-role answer = NO.)
  • client_sdk imports no dart:ui / Flutter widgets / flutter_secure_storage. (It already depends on the flutter SDK meta-package and package:supabase; that is allowed. The prohibition is on UI/engine/secure-storage deps, which stay app-only.)
  • One data path — Bloc → Repository → Client facade → Service → Adapter. The cloud adapter has zero business logic; rules stay in services. createClient({config}) is config-driven — never inject an adapter from the app.
  • Ledger surface is append-only — the cloud adapter implements only insertLedgerEntry + reads; there is no ledger update/delete anywhere.
  • Reuse the SP2 SupabaseClient built in createClient for the cloud adapter — do NOT construct a second client.
  • Enum wire convention — every domain enum serializes via .wireName and parses via Enum.fromWireName(String) (see local_storage_adapter.dart). The cloud mappers use the SAME convention.
  • Cloud column types are nativejsonb (send Dart Map/List, not a JSON-encoded string), text[]/int[] (send Dart List<String>/List<int>), timestamptz (send DateTime.toIso8601String(), parse with DateTime.parse). This is the ONE place cloud mappers differ from the local adapter, which jsonEncodes into TEXT because SQLite lacks arrays/jsonb.
  • Insert/update return the input model — port methods echo their argument (match the local adapter); no .select() round-trip needed.
  • Test rigor — async-throw assertions use await expectLater(future, throwsA(...)), never expect(() => future, throwsA(...)). Capture TRUE exit codes: cmd > /tmp/t.txt 2>&1; echo EXIT=$? — never pipe a test run to tail/head (it masks the exit code).
  • Codegen — if any task runs build_runner, run it scoped from packages/client_sdk and restore any clobbered .g.dart sibling from HEAD so only the intended file changes. (Most SP3 tasks add no @JsonSerializable and need no codegen.)
  • Anti-corruption git guardrails (every subagent): no git reset/checkout <path>/clean/stash/rebase/add -A; stage with explicit git add <file>; verify NO *_test.dart deletions before commit (git diff --cached --diff-filter=D --name-only shows none); assert the SDK + app suite counts are ≥ baseline before marking a task done.
  • Branch: all work on feat/sp3-cloud-data-adapter (already created; spec committed there).

File Structure

FileResponsibility
packages/client_sdk/lib/src/client/client_config.dart (modify)Add DataMode enum + dataMode field.
packages/client_sdk/lib/src/client/create_client.dart (modify)Branch to the cloud durable when dataMode == cloud.
packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart (create)PostgrestPort seam + shared mapping helpers (ISO datetime, jsonb/array coercion).
packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart (create)SupabaseStorageAdapter — holds the PostgrestPort, with the aggregate mixins, prod ctor wraps a SupabaseClient.
packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart (create)households + members port methods + mappers (mixin).
packages/client_sdk/lib/src/adapters/cloud/supabase_chores.dart (create)chores + submissions + completions.
packages/client_sdk/lib/src/adapters/cloud/supabase_economy.dart (create)approvals, ledger, token_batches, budget_categories, spend_requests, redemptions.
packages/client_sdk/lib/src/adapters/cloud/supabase_catalog.dart (create)rewards, activities, activity_gates, goals, places, entitlements.
packages/client_sdk/lib/src/models/exceptions.dart (modify)Add AuthorizationFailure + StorageFailure.
packages/client_sdk/lib/src/services/cloud_migration_service.dart (create)Free→paid upload (FK-ordered, UUID-preserving, idempotent, parity check).
packages/client_sdk/lib/src/client/client.dart + client_impl.dart (modify)Add linkAccountToMember + migrateLocalHouseholdToCloud facade methods.
packages/client_sdk/test/cloud/*.dart (create)Fake PostgREST + per-aggregate mapper/CRUD tests, error-mapping tests, migration tests.
app/test-gallery/.../architecture/*.md (modify, P6)Mark the cloud data path built.

The cloud adapter is split into mixins by aggregate group so each file stays <400 lines and each is independently reviewable. SupabaseStorageAdapter is class SupabaseStorageAdapter with Households, Chores, Economy, Catalog implements StoragePort.


Task 1: DataMode config seam + construction guard

Files:

  • Modify: packages/client_sdk/lib/src/client/client_config.dart
  • Modify: packages/client_sdk/lib/src/client/create_client.dart:29-39
  • Test: packages/client_sdk/test/create_client_data_mode_test.dart (create)

Interfaces:

  • Produces: enum DataMode { local, cloud }; ClientConfig.dataMode (default DataMode.local); createClient throws ArgumentError if dataMode == cloud && api == null. Cloud data wiring itself lands in Task 7.

  • Step 1: Write the failing test

// packages/client_sdk/test/create_client_data_mode_test.dart
import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
test('defaults to local data mode', () {
const config = ClientConfig();
expect(config.dataMode, DataMode.local);
});

test('cloud data mode without api throws ArgumentError', () {
expect(
() => createClient(config: const ClientConfig(dataMode: DataMode.cloud)),
throwsArgumentError,
);
});

test('local data mode builds a client (no network)', () {
final client = createClient(config: const ClientConfig());
expect(client, isA<Client>());
});
}
  • Step 2: Run test to verify it fails

Run from repo root: fvm flutter test packages/client_sdk/test/create_client_data_mode_test.dart > /tmp/t.txt 2>&1; echo EXIT=$? Expected: FAIL — DataMode undefined / dataMode not a parameter.

  • Step 3: Add DataMode + field to ClientConfig

In client_config.dart, above class ClientConfig:

/// Selects the DATA durable: [DataMode.local] = offline Drift (free tier);
/// [DataMode.cloud] = Supabase PostgREST under RLS (subscribed). Cloud requires
/// [ClientConfig.api] (the authed client). Default local keeps every existing
/// entrypoint unchanged. The APP sets cloud from subscription state — it can't
/// be derived from the `entitlements` row, which lives in the store being chosen.
enum DataMode { local, cloud }

Add to the constructor + fields + copyWith + props:

const ClientConfig({
this.api,
this.localStorageDirectory,
this.sessionStore,
this.enabledMethods = const {AuthMethod.emailPassword},
this.dataMode = DataMode.local,
});

/// Which data durable to use. See [DataMode].
final DataMode dataMode;

In copyWith, add DataMode? dataMode, param and dataMode: dataMode ?? this.dataMode,. In props, add dataMode.

  • Step 4: Add the guard to createClient

In create_client.dart, immediately after final api = config.api;:

if (config.dataMode == DataMode.cloud && api == null) {
throw ArgumentError(
'DataMode.cloud requires ClientConfig.api (the authed Supabase client).',
);
}

(Leave the rest of createClient unchanged — the real cloud branch lands in Task 7.)

  • Step 5: Run test to verify it passes

Run: fvm flutter test packages/client_sdk/test/create_client_data_mode_test.dart > /tmp/t.txt 2>&1; echo EXIT=$? Expected: PASS (EXIT=0).

  • Step 6: Commit
git add packages/client_sdk/lib/src/client/client_config.dart packages/client_sdk/lib/src/client/create_client.dart packages/client_sdk/test/create_client_data_mode_test.dart
git commit -m "feat(sdk): ClientConfig.dataMode seam + cloud-requires-api guard (SP3 P1)"

Task 2: Fake PostgREST + cloud adapter scaffold + shared row helpers + households/members

This task delivers the test harness (a hand-written fake of the PostgREST query surface), the adapter skeleton + cloud_rows.dart helpers, and households+members as the first proven aggregate group. Later tasks add the remaining groups against the same harness.

Files:

  • Create: packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart
  • Create: packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart
  • Create: packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart
  • Create: packages/client_sdk/test/cloud/fake_postgrest.dart
  • Test: packages/client_sdk/test/cloud/supabase_households_test.dart

Interfaces:

  • Consumes: StoragePort (packages/client_sdk/lib/src/adapters/adapter.dart), all models, the live column schema (below), local_storage_adapter.dart as the field-semantics reference.
  • Produces: PostgrestPort seam; SupabaseStorageAdapter(SupabaseClient) (prod) + .forTest(PostgrestPort); Households mixin implementing getHousehold/insertHousehold/updateHousehold/getMembers/watchMembers/insertMember/updateMember/deleteMember; cloud_rows.dart helpers dt(Object?), dtN(Object?), iso(DateTime), isoN(DateTime?), strList(Object?), intList(Object?).

Reference — the fake. package:supabase's SupabaseClient.from(table) returns a query builder; chaining .select()/.insert()/.update()/.upsert()/.delete()/.eq()/.order()/.limit()/.maybeSingle() yields awaitables returning List<Map<String,dynamic>> (or Map? for maybeSingle). Faking that class hierarchy directly is brittle, so the adapter talks to a narrow PostgrestPort seam the fake implements. The fake stores native Dart Map/List for jsonb/array columns (as PostgREST returns them) and does NOT enforce RLS (RLS is asserted in the P6 live smoke).

  • Step 1: Write the failing test (households + members round-trip)
// packages/client_sdk/test/cloud/supabase_households_test.dart
import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk/src/adapters/cloud/supabase_storage_adapter.dart';
import 'package:flutter_test/flutter_test.dart';
import 'fake_postgrest.dart';

void main() {
late FakePostgrest db;
late SupabaseStorageAdapter adapter;

setUp(() {
db = FakePostgrest();
adapter = SupabaseStorageAdapter.forTest(db);
});

test('insert then get household round-trips all fields', () async {
const h = Household(
id: 'h1', name: 'Nest',
currencyPerToken: 0.25,
split: EarningsSplit(give: 10, save: 40, spend: 50),
featureFlags: HouseholdFeatureFlags(),
emoji: '🏠',
);
await adapter.insertHousehold(h);
final got = await adapter.getHousehold();
expect(got, equals(h));

final row = db.rows('households').single;
expect(row['currency_per_token'], 0.25);
expect(row['split_give'], 10);
expect(row['feature_flags'], isA<Map<String, dynamic>>());
});

test('member round-trips roles/traits as native arrays', () async {
final m = HouseholdMember(
id: 'm1', householdId: 'h1', displayName: 'Sam',
kind: MemberKind.parent, roles: {MemberRole.admin},
status: MemberStatus.active, traits: const ['calm'],
authUserId: 'auth-1', watchOnly: false,
createdAt: DateTime.utc(2026, 1, 1),
);
await adapter.insertMember(m);
final row = db.rows('household_members').single;
expect(row['roles'], ['admin']); // text[] — Dart List, not a JSON string
expect(row['traits'], ['calm']);
expect(row['auth_user_id'], 'auth-1');
final got = (await adapter.getMembers('h1')).single;
expect(got, equals(m));
});
}

(Use the EXACT field names/types from each model — read the model file if unsure. Household requires currencyPerToken, split, featureFlags, emoji, createdAt; HouseholdMember requires the full field set seen in _memberFromRow at local_storage_adapter.dart:706. If a const constructor rejects a default createdAt, pass an explicit createdAt.)

  • Step 2: Run to verify it fails

Run: fvm flutter test packages/client_sdk/test/cloud/supabase_households_test.dart > /tmp/t.txt 2>&1; echo EXIT=$? Expected: FAIL — adapter/fake/seam undefined.

  • Step 3: Define the PostgrestPort seam + row helpers
// packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart
/// The narrow PostgREST surface the cloud adapter needs. Production wraps a
/// `SupabaseClient`; tests supply an in-memory fake. Methods return decoded
/// rows (jsonb/array columns already as Dart Map/List, exactly as PostgREST
/// returns them).
abstract class PostgrestPort {
Future<List<Map<String, dynamic>>> selectAll(String table);
Future<List<Map<String, dynamic>>> selectEq(
String table,
Map<String, Object?> filters, {
String? orderBy,
bool ascending = true,
int? limit,
});
Future<Map<String, dynamic>?> selectMaybeSingle(
String table,
Map<String, Object?> filters,
);
Future<void> insert(String table, Map<String, dynamic> values);
/// INSERT ... ON CONFLICT (id) DO NOTHING.
Future<void> upsertIgnore(String table, List<Map<String, dynamic>> rows);
Future<void> updateEq(
String table,
Map<String, dynamic> values,
Map<String, Object?> filters,
);
Future<void> deleteEq(String table, Map<String, Object?> filters);
}

DateTime dt(Object? v) => DateTime.parse(v! as String).toUtc();
DateTime? dtN(Object? v) => v == null ? null : DateTime.parse(v as String).toUtc();
String iso(DateTime v) => v.toUtc().toIso8601String();
String? isoN(DateTime? v) => v?.toUtc().toIso8601String();
List<String> strList(Object? v) =>
(v as List<dynamic>? ?? const []).map((e) => e as String).toList();
List<int> intList(Object? v) =>
(v as List<dynamic>? ?? const []).map((e) => e as int).toList();
// packages/client_sdk/test/cloud/fake_postgrest.dart
import 'package:client_sdk/src/adapters/cloud/cloud_rows.dart';

/// In-memory PostgrestPort. Stores native Map/List for jsonb/array columns
/// (as PostgREST returns them). Does NOT enforce RLS — asserted by the live
/// smoke (P6). `throwOnInsert` lets a test inject a PostgrestException.
class FakePostgrest implements PostgrestPort {
final Map<String, List<Map<String, dynamic>>> _tables = {};
Object? throwOnInsert; // set to simulate trigger/RLS errors

List<Map<String, dynamic>> rows(String t) => _tables[t] ?? const [];

bool _matches(Map<String, dynamic> row, Map<String, Object?> f) =>
f.entries.every((e) => row[e.key] == e.value);

@override
Future<List<Map<String, dynamic>>> selectAll(String t) async =>
List.of(_tables[t] ?? const []);

@override
Future<List<Map<String, dynamic>>> selectEq(
String t, Map<String, Object?> f,
{String? orderBy, bool ascending = true, int? limit}) async {
var out = (_tables[t] ?? const []).where((r) => _matches(r, f)).toList();
if (orderBy != null) {
out.sort((a, b) => Comparable.compare(
a[orderBy] as Comparable, b[orderBy] as Comparable));
if (!ascending) out = out.reversed.toList();
}
if (limit != null && out.length > limit) out = out.sublist(0, limit);
return out;
}

@override
Future<Map<String, dynamic>?> selectMaybeSingle(
String t, Map<String, Object?> f) async {
final m = (_tables[t] ?? const []).where((r) => _matches(r, f));
return m.isEmpty ? null : m.first;
}

@override
Future<void> insert(String t, Map<String, dynamic> v) async {
if (throwOnInsert != null) throw throwOnInsert!;
(_tables[t] ??= []).add(Map.of(v));
}

@override
Future<void> upsertIgnore(String t, List<Map<String, dynamic>> rows) async {
final tbl = _tables[t] ??= [];
for (final r in rows) {
if (tbl.any((e) => e['id'] == r['id'])) continue;
tbl.add(Map.of(r));
}
}

@override
Future<void> updateEq(
String t, Map<String, dynamic> v, Map<String, Object?> f) async {
for (final r in (_tables[t] ?? const [])) {
if (_matches(r, f)) r.addAll(v);
}
}

@override
Future<void> deleteEq(String t, Map<String, Object?> f) async {
_tables[t]?.removeWhere((r) => _matches(r, f));
}
}
  • Step 4: Adapter scaffold + production PostgrestPort over SupabaseClient
// packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart
import 'package:supabase/supabase.dart' show SupabaseClient;
import '../adapter.dart';
import 'cloud_rows.dart';
import 'supabase_households.dart';
// (later tasks add: supabase_chores.dart, supabase_economy.dart, supabase_catalog.dart)

/// Cloud [StoragePort] over Supabase PostgREST (RLS-enforced). Pure I/O — zero
/// domain logic. The ledger surface is append-only (insert + read only).
class SupabaseStorageAdapter
with Households /*, Chores, Economy, Catalog */
implements StoragePort {
SupabaseStorageAdapter(SupabaseClient client)
: db = _SupabaseRestPort(client); // Task 6 wraps this in MappingPort

/// Test seam — inject a fake PostgrestPort.
SupabaseStorageAdapter.forTest(this.db);

@override
final PostgrestPort db;
}

class _SupabaseRestPort implements PostgrestPort {
_SupabaseRestPort(this._c);
final SupabaseClient _c;

@override
Future<List<Map<String, dynamic>>> selectAll(String t) async =>
(await _c.from(t).select()).cast<Map<String, dynamic>>();

@override
Future<List<Map<String, dynamic>>> selectEq(
String t, Map<String, Object?> f,
{String? orderBy, bool ascending = true, int? limit}) async {
var q = _c.from(t).select();
f.forEach((k, v) => q = q.eq(k, v as Object));
var t2 = orderBy != null ? q.order(orderBy, ascending: ascending) : q;
if (limit != null) t2 = t2.limit(limit);
return (await t2).cast<Map<String, dynamic>>();
}

@override
Future<Map<String, dynamic>?> selectMaybeSingle(
String t, Map<String, Object?> f) async {
var q = _c.from(t).select();
f.forEach((k, v) => q = q.eq(k, v as Object));
return await q.limit(1).maybeSingle();
}

@override
Future<void> insert(String t, Map<String, dynamic> v) => _c.from(t).insert(v);

@override
Future<void> upsertIgnore(String t, List<Map<String, dynamic>> rows) =>
_c.from(t).upsert(rows, onConflict: 'id', ignoreDuplicates: true);

@override
Future<void> updateEq(
String t, Map<String, dynamic> v, Map<String, Object?> f) async {
var q = _c.from(t).update(v);
f.forEach((k, val) => q = q.eq(k, val as Object));
await q;
}

@override
Future<void> deleteEq(String t, Map<String, Object?> f) async {
var q = _c.from(t).delete();
f.forEach((k, val) => q = q.eq(k, val as Object));
await q;
}
}

Implementer note: confirm the exact PostgrestFilterBuilder/PostgrestTransformBuilder chaining types against the installed package:supabase ^2.10.6 so the var q = ...; q = q.eq(...) reassignments type-check. If the builder types differ, adjust the local variable types — the PostgrestPort seam contract does not change.

  • Step 5: Implement the Households mixin
// packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart
import '../../models/earnings_split.dart';
import '../../models/household.dart';
import '../../models/household_feature_flags.dart';
import '../../models/household_member.dart';
import '../../models/member_kind.dart';
import '../../models/member_role.dart';
import '../../models/member_status.dart';
import 'cloud_rows.dart';

mixin Households {
PostgrestPort get db;

Future<Household?> getHousehold() async {
final row = await db.selectMaybeSingle('households', const {});
return row == null ? null : _household(row);
}

Future<Household> insertHousehold(Household h) async {
await db.insert('households', _householdValues(h));
return h;
}

Future<Household> updateHousehold(Household h) async {
await db.updateEq('households', _householdValues(h)..remove('id'),
{'id': h.id});
return h;
}

Future<List<HouseholdMember>> getMembers(String householdId) async {
final rows =
await db.selectEq('household_members', {'household_id': householdId});
return rows.map(_member).toList();
}

Stream<List<HouseholdMember>> watchMembers(String householdId) async* {
yield await getMembers(householdId); // hydrate-once (SP3); Realtime = SP3.5
}

Future<HouseholdMember> insertMember(HouseholdMember m) async {
await db.insert('household_members', _memberValues(m));
return m;
}

Future<HouseholdMember> updateMember(HouseholdMember m) async {
await db.updateEq('household_members', _memberValues(m)..remove('id'),
{'id': m.id});
return m;
}

Future<void> deleteMember(String id) =>
db.deleteEq('household_members', {'id': id});

// ── mappers ──
Household _household(Map<String, dynamic> r) => Household(
id: r['id'] as String,
name: r['name'] as String,
currencyPerToken: (r['currency_per_token'] as num).toDouble(),
split: EarningsSplit(
give: r['split_give'] as int,
save: r['split_save'] as int,
spend: r['split_spend'] as int,
),
featureFlags: HouseholdFeatureFlags.fromJson(
(r['feature_flags'] as Map).cast<String, dynamic>()),
emoji: r['emoji'] as String?,
createdAt: dt(r['created_at']),
);

Map<String, dynamic> _householdValues(Household h) => {
'id': h.id,
'name': h.name,
'currency_per_token': h.currencyPerToken,
'split_give': h.split.give,
'split_save': h.split.save,
'split_spend': h.split.spend,
'feature_flags': h.featureFlags.toJson(), // jsonb — Map, not a string
'emoji': h.emoji,
'created_at': iso(h.createdAt),
};

HouseholdMember _member(Map<String, dynamic> r) => HouseholdMember(
id: r['id'] as String,
householdId: r['household_id'] as String,
displayName: r['display_name'] as String,
kind: MemberKind.fromWireName(r['kind'] as String),
roles: strList(r['roles']).map(MemberRole.fromWireName).toSet(),
status: MemberStatus.fromWireName(r['status'] as String),
age: r['age'] as int?,
authUserId: r['auth_user_id'] as String?,
email: r['email'] as String?,
inviteToken: r['invite_token'] as String?,
invitedAt: dtN(r['invited_at']),
inviteNote: r['invite_note'] as String?,
expiresAt: dtN(r['expires_at']),
termsVersionAccepted: r['terms_version_accepted'] as int?,
country: r['country'] as String?,
emoji: r['emoji'] as String?,
colorKey: r['color_key'] as String?,
traits: strList(r['traits']),
homePlaceId: r['home_place_id'] as String?,
watchOnly: r['watch_only'] as bool,
createdAt: dt(r['created_at']),
);

Map<String, dynamic> _memberValues(HouseholdMember m) => {
'id': m.id,
'household_id': m.householdId,
'display_name': m.displayName,
'kind': m.kind.wireName,
// text[] — emit wire names in enum-declaration order (matches local adapter)
'roles': MemberRole.values
.where(m.roles.contains)
.map((r) => r.wireName)
.toList(),
'status': m.status.wireName,
'age': m.age,
'auth_user_id': m.authUserId,
'email': m.email,
'invite_token': m.inviteToken,
'invited_at': isoN(m.invitedAt),
'invite_note': m.inviteNote,
'expires_at': isoN(m.expiresAt),
'terms_version_accepted': m.termsVersionAccepted,
'country': m.country,
'emoji': m.emoji,
'color_key': m.colorKey,
'traits': m.traits, // text[]
'home_place_id': m.homePlaceId,
'watch_only': m.watchOnly,
'created_at': iso(m.createdAt),
};
}

(@override on StoragePort methods may live on the mixin members. If the analyzer objects to @override in a mixin, the reviewer will confirm the cleanest form — either keep them un-annotated in the mixin or re-declare thin overrides on SupabaseStorageAdapter.)

  • Step 6: Run the test to verify it passes

Run: fvm flutter test packages/client_sdk/test/cloud/supabase_households_test.dart > /tmp/t.txt 2>&1; echo EXIT=$? Expected: PASS (EXIT=0).

  • Step 7: Commit
git add packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart packages/client_sdk/test/cloud/fake_postgrest.dart packages/client_sdk/test/cloud/supabase_households_test.dart
git commit -m "feat(sdk): cloud adapter scaffold + households/members port + fake PostgREST (SP3 P2)"

Task 3: Chores mixin (chores + submissions + completions)

Files:

  • Create: packages/client_sdk/lib/src/adapters/cloud/supabase_chores.dart
  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart (add Chores to the with clause + import)
  • Test: packages/client_sdk/test/cloud/supabase_chores_test.dart

Interfaces:

  • Produces: Chores mixin implementing getChores/getChore/watchChores/insertChore/updateChore/deleteChore/getSubmission/getSubmissions/insertSubmission/getCompletions/insertCompletion.

Column map (chores) — from the live schema; jsonb = native Map/List, weekly_days = int[], temp_bonus_until = text wireName: id, household_id, name, kind(wire), frequency(wire), token_value, min_age, subtasks(jsonb: List of Subtask.toJson()), claimed_by_member_id, approval_policy(wire), auto_approved_by_member_id, weekly_days(int[]), is_active, emoji, place_id, assigned_member_ids(jsonb List<String>), room_assignees(jsonb Map<String,List<String>>), steps_per_member(jsonb Map<String,List of Subtask.toJson()>), max_per_day, estimate_min, temp_bonus_tokens, temp_bonus_until(wire or null), created_at.

  • Step 1: Write the failing test — round-trip a Chore with subtasks, weeklyDays, roomAssignees, stepsPerMember, tempBonus; assert db.rows('chores').single['weekly_days'] is [1,3] (a List, not a string) and ['subtasks'] is a List<Map>. Round-trip a ChoreSubmission + ChoreCompletion, and test the getSubmissions(memberId:, choreId:) filters. Use the exact Chore/Subtask/ChoreSubmission/ChoreCompletion constructors from their model files and _choreFromRow at local_storage_adapter.dart:770 as the field-semantics reference.

  • Step 2: Run to verify it fails. fvm flutter test packages/client_sdk/test/cloud/supabase_chores_test.dart > /tmp/t.txt 2>&1; echo EXIT=$? → FAIL.

  • Step 3: Implement Chores mixin. Mirror the _choreFromRow/_choreCompanion field mapping in local_storage_adapter.dart, but: read jsonb columns as already-decoded (r['subtasks'] as List), read weekly_days/arrays via intList/strList, parse timestamps via dt/dtN, and on WRITE emit native List/Map (NOT jsonEncode). Subtask maps use Subtask.toJson()/Subtask.fromJson. room_assignees writes chore.roomAssignees (a Map<String,List<String>>) directly; reads (r['room_assignees'] as Map).map((k,v) => MapEntry(k as String, strList(v))). steps_per_member writes chore.stepsPerMember.map((k,v) => MapEntry(k, v.map((s)=>s.toJson()).toList())); reads symmetrically with Subtask.fromJson. temp_bonus_until writes chore.tempBonusUntil?.wireName and reads TempBonusUntil.fromWireName when non-null. getSubmissions/getCompletions build the filter conditionally: {'household_id': id, if (memberId != null) 'member_id': memberId, if (choreId != null) 'chore_id': choreId}. watchChores is async* { yield await getChores(id); }.

  • Step 4: Run to verify it passes. Expected PASS (EXIT=0).

  • Step 5: Commit. git add the three files; git commit -m "feat(sdk): cloud adapter chores/submissions/completions (SP3 P2)"


Task 4: Economy mixin (approvals, ledger, token_batches, budget_categories, spend_requests, redemptions)

Files:

  • Create: packages/client_sdk/lib/src/adapters/cloud/supabase_economy.dart
  • Modify: supabase_storage_adapter.dart (with + import)
  • Test: packages/client_sdk/test/cloud/supabase_economy_test.dart

Interfaces:

  • Produces: Economy mixin implementing getApproval/watchApprovals/insertApproval/updateApproval/getLedgerEntries/watchLedgerEntries/insertLedgerEntry/getTokenBatches/insertTokenBatch/getBudgetCategories/insertBudgetCategory/getSpendRequest/insertSpendRequest/updateSpendRequest/getRedemptions/watchRedemptions/insertRedemption.

Mapping references (local adapter): _approvalFromRow/_approvalCompanion (local:876 — note resolved_by is a nullable wire enum ResolvedBy, distinct from resolved_by_member_id), _ledgerEntryFromRow (local:909), _tokenBatchFromRow (local:920), _spendRequestFromRow/_spendRequestCompanion (local:933), _redemptionFromRow (local:958), and the inline BudgetCategory mapper at local:373. All buckets/kinds/statuses use .wireName/fromWireName. getLedgerEntries/getTokenBatches/getRedemptions filter {'household_id': id, if (memberId != null) 'member_id': memberId}. getRedemptions/watchRedemptions order by redeemed_at ascending (orderBy: 'redeemed_at'). watchApprovals does a direct selectEq('approvals', {'household_id': id}) then maps (there is no getApprovals). watchLedgerEntries/watchRedemptions/watchApprovals are async* { yield <snapshot>; }.

  • Step 1: Write the failing test — round-trip one of each aggregate; assert insertLedgerEntry writes delta/bucket(wire)/kind(wire); assert getRedemptions returns ascending by redeemed_at; assert watchApprovals(id).first emits the inserted approval; assert an approval with a non-null resolvedBy (e.g. ResolvedBy.autoPolicy) round-trips.
  • Step 2: Run → FAIL. fvm flutter test packages/client_sdk/test/cloud/supabase_economy_test.dart > /tmp/t.txt 2>&1; echo EXIT=$?
  • Step 3: Implement Economy mixin mirroring the cited local mappers, native-typed + ISO timestamps as in Task 2.
  • Step 4: Run → PASS.
  • Step 5: Commit. git commit -m "feat(sdk): cloud adapter economy aggregates (SP3 P2)"

Task 5: Catalog mixin (rewards, activities, activity_gates, goals, places, entitlements)

Files:

  • Create: packages/client_sdk/lib/src/adapters/cloud/supabase_catalog.dart
  • Modify: supabase_storage_adapter.dart (with + import) — after this task the clause is with Households, Chores, Economy, Catalog.
  • Test: packages/client_sdk/test/cloud/supabase_catalog_test.dart

Interfaces:

  • Produces: Catalog mixin implementing getRewards/getReward/insertReward/updateReward/deleteReward/getActivities/getActivity/insertActivity/updateActivity/deleteActivity/getActivityGates/insertActivityGate/deleteActivityGate/getGoals/getGoal/insertGoal/updateGoal/getPlaces/insertPlace/deletePlace/getEntitlements/insertEntitlement.

Mapping references: _rewardFromRow/_rewardCompanion (local:967), _activityFromRow/_activityCompanion (local:989), the inline ActivityGate mapper (local:542), _goalFromRow/_goalCompanion (local:1014scope/status wire enums; member_id nullable; image_url/due_label), the inline Place mapper (local:618), and the Entitlement mapper (local:661, PlanKey.fromWireName/.wireName). getGoals filters {'household_id': id, if (memberId != null) 'member_id': memberId}.

  • Step 1: Write the failing test — round-trip each aggregate; assert a goal with scope: GoalScope.family, null memberId, and a status round-trips; assert an entitlement's plan_key is the wire string.
  • Step 2: Run → FAIL.
  • Step 3: Implement Catalog mixin.
  • Step 4: Run → PASS, then run the WHOLE cloud test dir to confirm full-adapter coherence: fvm flutter test packages/client_sdk/test/cloud > /tmp/t.txt 2>&1; echo EXIT=$? (expect EXIT=0). If the workspace Could not find package test quirk from the RESUME doc appears, run the app suite as the practical gate and record it.
  • Step 5: Commit. git commit -m "feat(sdk): cloud adapter catalog aggregates — full StoragePort coverage (SP3 P2)"

Task 6: Postgres → SDK domain exception mapping

Files:

  • Modify: packages/client_sdk/lib/src/models/exceptions.dart (add AuthorizationFailure, StorageFailure)
  • Create: packages/client_sdk/lib/src/adapters/cloud/mapping_port.dart (the MappingPort decorator)
  • Modify: supabase_storage_adapter.dart (prod ctor wraps _SupabaseRestPort in MappingPort)
  • Test: packages/client_sdk/test/cloud/cloud_error_mapping_test.dart

Interfaces:

  • Produces: AuthorizationFailure(String message), StorageFailure(String message); MappingPort(PostgrestPort inner) translates PostgrestException by code: 23514 (zero-floor) → InsufficientBalanceException; 42501 (RLS) → AuthorizationFailure; else → StorageFailure. Raw PostgREST message/details are logged via dart:developer, never surfaced.

Behavioural parity: the local tier surfaces InsufficientBalanceException on a zero-floor breach. The cloud tier must surface the SAME type so blocs behave identically regardless of tier.

  • Step 1: Write the failing test
import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk/src/adapters/cloud/mapping_port.dart';
import 'package:client_sdk/src/adapters/cloud/supabase_storage_adapter.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:supabase/supabase.dart' show PostgrestException;
import 'fake_postgrest.dart';

void main() {
test('zero-floor 23514 → InsufficientBalanceException', () async {
final fake = FakePostgrest()
..throwOnInsert = const PostgrestException(
message: 'zero floor violated', code: '23514');
final a = SupabaseStorageAdapter.forTest(MappingPort(fake));
await expectLater(
a.insertLedgerEntry(_anEntry()),
throwsA(isA<InsufficientBalanceException>()),
);
});

test('RLS 42501 → AuthorizationFailure (no raw message leak)', () async {
final fake = FakePostgrest()
..throwOnInsert = const PostgrestException(
message: 'permission denied for table chores SECRET', code: '42501');
final a = SupabaseStorageAdapter.forTest(MappingPort(fake));
try {
await a.insertChore(_aChore());
fail('expected AuthorizationFailure');
} on AuthorizationFailure catch (e) {
expect(e.message, isNot(contains('SECRET')));
}
});
}

(Provide _anEntry()/_aChore() builders with valid required fields.)

  • Step 2: Run → FAIL. fvm flutter test packages/client_sdk/test/cloud/cloud_error_mapping_test.dart > /tmp/t.txt 2>&1; echo EXIT=$?

  • Step 3: Add exceptions + the MappingPort decorator

// in exceptions.dart
class AuthorizationFailure implements Exception {
const AuthorizationFailure(this.message);
final String message;
@override
String toString() => 'AuthorizationFailure: $message';
}

class StorageFailure implements Exception {
const StorageFailure(this.message);
final String message;
@override
String toString() => 'StorageFailure: $message';
}
// packages/client_sdk/lib/src/adapters/cloud/mapping_port.dart
import 'dart:developer' as dev;
import 'package:supabase/supabase.dart' show PostgrestException;
import '../../models/exceptions.dart';
import 'cloud_rows.dart';

/// Decorates a PostgrestPort, translating PostgrestException → SDK domain
/// exceptions. Raw PostgREST detail never reaches the UI.
class MappingPort implements PostgrestPort {
MappingPort(this._inner);
final PostgrestPort _inner;

Future<T> _w<T>(Future<T> Function() op) async {
try {
return await op();
} on PostgrestException catch (e) {
dev.log('postgrest error', name: 'SupabaseStorageAdapter',
level: 900, error: '${e.code}: ${e.message}');
throw switch (e.code) {
'23514' => const InsufficientBalanceException(
'That would take a bucket below zero.'),
'42501' => const AuthorizationFailure(
'You do not have access to that household record.'),
_ => const StorageFailure('A storage error occurred. Please retry.'),
};
}
}

@override
Future<List<Map<String, dynamic>>> selectAll(String t) =>
_w(() => _inner.selectAll(t));
@override
Future<List<Map<String, dynamic>>> selectEq(String t, Map<String, Object?> f,
{String? orderBy, bool ascending = true, int? limit}) =>
_w(() => _inner.selectEq(t, f,
orderBy: orderBy, ascending: ascending, limit: limit));
@override
Future<Map<String, dynamic>?> selectMaybeSingle(
String t, Map<String, Object?> f) =>
_w(() => _inner.selectMaybeSingle(t, f));
@override
Future<void> insert(String t, Map<String, dynamic> v) =>
_w(() => _inner.insert(t, v));
@override
Future<void> upsertIgnore(String t, List<Map<String, dynamic>> rows) =>
_w(() => _inner.upsertIgnore(t, rows));
@override
Future<void> updateEq(
String t, Map<String, dynamic> v, Map<String, Object?> f) =>
_w(() => _inner.updateEq(t, v, f));
@override
Future<void> deleteEq(String t, Map<String, Object?> f) =>
_w(() => _inner.deleteEq(t, f));
}

Wire it in the production constructor: SupabaseStorageAdapter(client) : db = MappingPort(_SupabaseRestPort(client)).

  • Step 4: Run → PASS. Re-run the full cloud suite (success paths pass through MappingPort unchanged).
  • Step 5: Commit. git add exceptions + mapping_port + adapter + test; git commit -m "feat(sdk): cloud error mapping → SDK domain exceptions, no PII leak (SP3 P3)"

Task 7: Wire the cloud durable into createClient

Files:

  • Modify: packages/client_sdk/lib/src/client/create_client.dart
  • Test: packages/client_sdk/test/create_client_cloud_wiring_test.dart

Interfaces:

  • Consumes: SupabaseStorageAdapter, CachedStorageAdapter, InMemoryStorageAdapter, the SP2 supabaseClient/supabaseAuth.

  • Produces: when dataMode == cloud, the returned Client's data path is CachedStorageAdapter(durable: SupabaseStorageAdapter(supabaseClient), cache: InMemoryStorageAdapter()); auth is the same SupabaseAuth.

  • Step 1: Write the failing testcreateClient with a cloud config builds a Client without throwing and without touching disk. Constructing a real SupabaseClient needs only a URL/key (no network at construction), so pass ApiConfig(url: Uri.parse('https://x.supabase.co'), anonKey: 'sb_publishable_x') + dataMode: DataMode.cloud and assert createClient(...) returns an isA<Client>(). (Behavioural cloud round-trips live in the P6 live smoke.)

  • Step 2: Run → FAIL (cloud branch not present; today it builds the Drift store unconditionally).

  • Step 3: Add the cloud branch. In create_client.dart, build the local store only when needed, and branch on dataMode after the SP2 supabaseClient/supabaseAuth are constructed:

// Free tier (no api) keeps the existing local path untouched.
if (api == null) {
final storage = buildLocalStore(config.localStorageDirectory);
return clientFromPort(storage);
}

// ... build supabaseClient + supabaseAuth exactly as today (SP2) ...

final StoragePort dataPort = config.dataMode == DataMode.cloud
? CachedStorageAdapter(
durable: SupabaseStorageAdapter(supabaseClient),
cache: InMemoryStorageAdapter(),
)
: buildLocalStore(config.localStorageDirectory);
return clientFromPort(dataPort, auth: supabaseAuth);

Add imports for CachedStorageAdapter, InMemoryStorageAdapter, SupabaseStorageAdapter, and StoragePort. Preserve the SP2 PKCE/session-store wiring exactly.

  • Step 4: Run → PASS. Then run the SDK + app suites to confirm the local/free paths still pass: fvm flutter test packages/client_sdk/test app/test > /tmp/t.txt 2>&1; echo EXIT=$? (expect EXIT=0; if the SDK workspace quirk appears, the app suite is the gate).

  • Step 5: Commit. git commit -m "feat(sdk): createClient cloud-durable branch (SP3 P2/P7)"


Task 8: Member-profile linkage paths (account-first + shadow-claim)

Files:

  • Modify: packages/client_sdk/lib/src/services/household_service.dart (add linkAccountToMember)
  • Modify: packages/client_sdk/lib/src/client/client.dart + client_impl.dart (facade method)
  • Test: packages/client_sdk/test/household_link_account_test.dart

Interfaces:

  • Consumes: existing addMember({..., String? authUserId}) (already threads auth_user_id); StoragePort.getMembers/updateMember; HouseholdMember.copyWith.

  • Produces: Future<HouseholdMember> linkAccountToMember({required String memberId, required String authUserId}) on HouseholdService + the Client facade — sets auth_user_id on an existing member whose authUserId is null; throws DomainRuleException if the member already has a different authUserId.

  • Step 1: Write the failing test (use the in-memory client from client_sdk_testing): create a child member with no authUserId; call linkAccountToMember; assert the reloaded member has the authUserId and that a ledger entry keyed by its member_id is unchanged. Second test: linking a member that already has a different authUserId throws DomainRuleException. Third test (account-first): addMember(displayName: 'Pat', kind: MemberKind.parent, authUserId: 'a1') persists the link on insert.

  • Step 2: Run → FAIL.

  • Step 3: Implement linkAccountToMember in HouseholdService:

Future<HouseholdMember> linkAccountToMember({
required String memberId,
required String authUserId,
}) async {
final household = await _storage.getHousehold();
if (household == null) throw const DomainRuleException('No household.');
final members = await _storage.getMembers(household.id);
final member = members.firstWhere(
(m) => m.id == memberId,
orElse: () => throw const ValidationException('Unknown member.'),
);
if (member.authUserId != null && member.authUserId != authUserId) {
throw const DomainRuleException(
'This profile is already linked to a different account.');
}
final linked = member.copyWith(authUserId: authUserId);
return _storage.updateMember(linked);
}

(Confirm HouseholdService field name for the storage port — match the existing private field. Confirm HouseholdMember.copyWith exposes authUserId; if not, add it to copyWith.) Add the facade delegate in client_impl.dart + the abstract signature in client.dart.

  • Step 4: Run → PASS.
  • Step 5: Commit. git commit -m "feat(sdk): linkAccountToMember + account-first member creation (SP3 P4)"

Task 9: CloudMigrationService — free→paid upload (FK-ordered, UUID-preserving, idempotent)

Files:

  • Create: packages/client_sdk/lib/src/services/cloud_migration_service.dart
  • Modify: client.dart + client_impl.dart (facade migrateLocalHouseholdToCloud)
  • Test: packages/client_sdk/test/cloud_migration_test.dart

Interfaces:

  • Consumes: a source StoragePort (the local Drift/in-memory store) and a target SupabaseStorageAdapter; authUserId.
  • Produces: CloudMigrationService(StoragePort source, SupabaseStorageAdapter target) with Future<MigrationResult> upload({required String authUserId}). MigrationResult = {bool complete, Map<String,int> sourceCounts, Map<String,int> targetCounts}.

Algorithm (exact):

  1. source.getHousehold(). If null → throw DomainRuleException('Nothing to migrate.').
  2. Read all source members. Pick the owner = first member with kind == MemberKind.parent. If none → throw DomainRuleException('Household has no parent to own the cloud copy.'). Set the owner's authUserId = authUserId (copyWith).
  3. Upload in FK-topological order, preserving ids, via the target's single-row port methods (target.insertHousehold, target.insertMember, …), wrapping each call so a PostgrestException with code 23505 (unique violation = already uploaded) is swallowed as "skip": households (the household row) → owner member (first member — satisfies the bootstrap RLS INSERT clause) → remaining members → placeschoresrewardsactivitiesactivity_gatesbudget_categoriesgoalschore_submissionschore_completionsspend_requestsredemptionsapprovalstoken_batchesentitlements. (Using the tested single-row mappers/inserts gives idempotency via the 23505 swallow and avoids any new bulk-mapper surface. After the owner exists, the user is parental so RLS passes for the rest.)
  4. Ledger LAST, special-cased for the zero-floor trigger. The BEFORE INSERT zero-floor trigger fires even on ON CONFLICT DO NOTHING, so re-inserting an existing entry would double-count and could spuriously trip the floor. Therefore: read existing cloud ledger ids (target.getLedgerEntries(householdId) → set of ids), then insert ONLY the source entries whose id is NOT already present, sorted by createdAt ascending, one-by-one via target.insertLedgerEntry (chronological order keeps every intermediate balance valid). Never upsert/retry an existing ledger row.
  5. Parity check: for each aggregate, compare source count vs a fresh target count. complete = all equal. Return the MigrationResult.
  • Step 1: Write the failing test — seed an in-memory source (via client_sdk_testing) with a full household (a parent + a child member, places, chores, rewards/activities/gates, goals, budget categories, spend_requests + redemptions + approvals, token_batches, entitlements, and a ledger with earns then spends). Construct target = SupabaseStorageAdapter.forTest(MappingPort(FakePostgrest())). Run upload(authUserId: 'a1'); assert result.complete is true, the owner member in target has authUserId == 'a1', and folded target-ledger balances equal source balances. Idempotency test: run upload twice → identical counts, no throw. Resume test: set the fake to throw on the Nth insert, run upload (catch), clear the fault, re-run → completes with correct counts.

  • Step 2: Run → FAIL. fvm flutter test packages/client_sdk/test/cloud_migration_test.dart > /tmp/t.txt 2>&1; echo EXIT=$?

  • Step 3: Implement CloudMigrationService per the algorithm. Provide a private _skipDup(Future<void> Function() op) that runs op and swallows PostgrestException code 23505. Read source aggregates via the source port's getters; for approvals (no list getter) drain source.watchApprovals(id).first.

  • Step 4: Add facade migrateLocalHouseholdToCloud — note in the file header that the APP wires this during the subscribe flow by building both a local source and a cloud target; there is no app UI in this task (billing detection is out of scope).

  • Step 5: Run → PASS.

  • Step 6: Commit. git commit -m "feat(sdk): CloudMigrationService free→paid upload — idempotent, ledger-safe (SP3 P5)"


Task 10: Guarded live integration smoke + docs + graphify

Files:

  • Create: packages/client_sdk/test/cloud/live_smoke_test.dart (skipped unless creds present)
  • Modify: app/test-gallery/authored/developer/architecture/code.md + containers.md + the auth page (mark the cloud data path built)
  • Then: graphify update .

Interfaces:

  • Consumes: app/config/supabase.local.json (gitignored) for URL + publishable key; project bgedvvmihygwxhjxlvfu.

  • Step 1: Write a guarded live smoke that skips when const String.fromEnvironment('SUPABASE_URL') is empty. With creds: sign up/in two users (extends the SP2 smoke), each creates a household via the cloud client, writes one aggregate, and asserts user A cannot read user B's household (RLS isolation) and that a zero-floor debit throws InsufficientBalanceException. Tag the test tags: ['live'].

  • Step 2: Run the smoke with credsthe controller runs this, not a subagent (it touches the live project): fvm flutter test packages/client_sdk/test/cloud/live_smoke_test.dart --dart-define-from-file=app/config/supabase.local.json > /tmp/t.txt 2>&1; echo EXIT=$? Expected: PASS (EXIT=0), or a clean skip if creds absent. Record the result.

  • Step 3: Update the architecture docs — promote the cloud DATA adapter from "planned/SP3 stub" to "built" in code.md/containers.md; note hydrate-once (Realtime = SP3.5). Update the spec/RESUME pointers.

  • Step 4: Run graphify update . to refresh the graph for the new cloud adapter files.

  • Step 5: Commit. git add the smoke + docs + graphify-out/graph.json + GRAPH_REPORT.md; git commit -m "test(sdk): guarded cloud live smoke + arch docs + graphify (SP3 P6)"


Self-Review (run after drafting; fix inline)

Spec coverage: P1=Task1; P2=Tasks2–5,7; P3=Task6; P4=Task8; P5=Task9; P6=Task10. Hybrid topology=Task7. Identity/linkage=Task8 (+ account-first via existing addMember(authUserId:)). Migration ledger-safety=Task9 step4. Realtime-out=watch* async* single-emit (Tasks2–5). Error mapping=Task6. ✅ Every spec section maps to a task.

Type consistency: PostgrestPort seam + cloud_rows.dart helpers (dt/dtN/iso/isoN/strList/intList) are referenced identically across Tasks 2–5. Enum convention .wireName/fromWireName is uniform. SupabaseStorageAdapter(SupabaseClient) prod ctor + .forTest(PostgrestPort) test ctor are stable. MappingPort wraps _SupabaseRestPort in prod (Task6) and the fake in error tests (Task6) and the migration test (Task9).

Placeholder scan: the mechanical mixins (Tasks 3–5) intentionally cite local_storage_adapter.dart line anchors + the live column map rather than transcribing ~40 near-identical mapper lines each — the implementer has the exact interface (adapter.dart), the field-semantics reference, the column schema, the canonical fully-worked pattern (Task 2), and behavioural tests that pin every field. This is DRY, not a placeholder. Tasks 1, 2, 6, 7, 8, 9 contain complete code for every non-mechanical decision.