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); adddatabase-reviewerfor any task touching PostgREST queries / RLS reliance / the migration (P2, P5, P6); addsecurity-reviewerfor 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_sdkimports nodart:ui/ Flutter widgets /flutter_secure_storage. (It already depends on theflutterSDK meta-package andpackage: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
SupabaseClientbuilt increateClientfor the cloud adapter — do NOT construct a second client. - Enum wire convention — every domain enum serializes via
.wireNameand parses viaEnum.fromWireName(String)(seelocal_storage_adapter.dart). The cloud mappers use the SAME convention. - Cloud column types are native —
jsonb(send DartMap/List, not a JSON-encoded string),text[]/int[](send DartList<String>/List<int>),timestamptz(sendDateTime.toIso8601String(), parse withDateTime.parse). This is the ONE place cloud mappers differ from the local adapter, whichjsonEncodes 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(...)), neverexpect(() => future, throwsA(...)). Capture TRUE exit codes:cmd > /tmp/t.txt 2>&1; echo EXIT=$?— never pipe a test run totail/head(it masks the exit code). - Codegen — if any task runs
build_runner, run it scoped frompackages/client_sdkand restore any clobbered.g.dartsibling from HEAD so only the intended file changes. (Most SP3 tasks add no@JsonSerializableand need no codegen.) - Anti-corruption git guardrails (every subagent): no
git reset/checkout <path>/clean/stash/rebase/add -A; stage with explicitgit add <file>; verify NO*_test.dartdeletions before commit (git diff --cached --diff-filter=D --name-onlyshows 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
| File | Responsibility |
|---|---|
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(defaultDataMode.local);createClientthrowsArgumentErrorifdataMode == 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 toClientConfig
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.dartas the field-semantics reference. - Produces:
PostgrestPortseam;SupabaseStorageAdapter(SupabaseClient)(prod) +.forTest(PostgrestPort);Householdsmixin implementinggetHousehold/insertHousehold/updateHousehold/getMembers/watchMembers/insertMember/updateMember/deleteMember;cloud_rows.darthelpersdt(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
PostgrestPortseam + 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
PostgrestPortoverSupabaseClient
// 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/PostgrestTransformBuilderchaining types against the installedpackage:supabase ^2.10.6so thevar q = ...; q = q.eq(...)reassignments type-check. If the builder types differ, adjust the local variable types — thePostgrestPortseam contract does not change.
- Step 5: Implement the
Householdsmixin
// 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(addChoresto thewithclause + import) - Test:
packages/client_sdk/test/cloud/supabase_chores_test.dart
Interfaces:
- Produces:
Choresmixin implementinggetChores/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 aList<Map>. Round-trip aChoreSubmission+ChoreCompletion, and test thegetSubmissions(memberId:, choreId:)filters. Use the exactChore/Subtask/ChoreSubmission/ChoreCompletionconstructors from their model files and_choreFromRowatlocal_storage_adapter.dart:770as 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
Choresmixin. Mirror the_choreFromRow/_choreCompanionfield mapping inlocal_storage_adapter.dart, but: read jsonb columns as already-decoded (r['subtasks'] as List), readweekly_days/arrays viaintList/strList, parse timestamps viadt/dtN, and on WRITE emit nativeList/Map(NOTjsonEncode). Subtask maps useSubtask.toJson()/Subtask.fromJson.room_assigneeswriteschore.roomAssignees(aMap<String,List<String>>) directly; reads(r['room_assignees'] as Map).map((k,v) => MapEntry(k as String, strList(v))).steps_per_memberwriteschore.stepsPerMember.map((k,v) => MapEntry(k, v.map((s)=>s.toJson()).toList())); reads symmetrically withSubtask.fromJson.temp_bonus_untilwriteschore.tempBonusUntil?.wireNameand readsTempBonusUntil.fromWireNamewhen non-null.getSubmissions/getCompletionsbuild the filter conditionally:{'household_id': id, if (memberId != null) 'member_id': memberId, if (choreId != null) 'chore_id': choreId}.watchChoresisasync* { yield await getChores(id); }. -
Step 4: Run to verify it passes. Expected PASS (EXIT=0).
-
Step 5: Commit.
git addthe 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:
Economymixin implementinggetApproval/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
insertLedgerEntrywritesdelta/bucket(wire)/kind(wire); assertgetRedemptionsreturns ascending byredeemed_at; assertwatchApprovals(id).firstemits the inserted approval; assert an approval with a non-nullresolvedBy(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
Economymixin 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 iswith Households, Chores, Economy, Catalog. - Test:
packages/client_sdk/test/cloud/supabase_catalog_test.dart
Interfaces:
- Produces:
Catalogmixin implementinggetRewards/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:1014 — scope/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, nullmemberId, and astatusround-trips; assert an entitlement'splan_keyis the wire string. - Step 2: Run → FAIL.
- Step 3: Implement
Catalogmixin. - 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 workspaceCould not find package testquirk 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(addAuthorizationFailure,StorageFailure) - Create:
packages/client_sdk/lib/src/adapters/cloud/mapping_port.dart(theMappingPortdecorator) - Modify:
supabase_storage_adapter.dart(prod ctor wraps_SupabaseRestPortinMappingPort) - Test:
packages/client_sdk/test/cloud/cloud_error_mapping_test.dart
Interfaces:
- Produces:
AuthorizationFailure(String message),StorageFailure(String message);MappingPort(PostgrestPort inner)translatesPostgrestExceptionby code:23514(zero-floor) →InsufficientBalanceException;42501(RLS) →AuthorizationFailure; else →StorageFailure. Raw PostgRESTmessage/detailsare logged viadart: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
MappingPortdecorator
// 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
MappingPortunchanged). - Step 5: Commit.
git addexceptions + 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 SP2supabaseClient/supabaseAuth. -
Produces: when
dataMode == cloud, the returnedClient's data path isCachedStorageAdapter(durable: SupabaseStorageAdapter(supabaseClient), cache: InMemoryStorageAdapter()); auth is the sameSupabaseAuth. -
Step 1: Write the failing test —
createClientwith a cloud config builds aClientwithout throwing and without touching disk. Constructing a realSupabaseClientneeds only a URL/key (no network at construction), so passApiConfig(url: Uri.parse('https://x.supabase.co'), anonKey: 'sb_publishable_x')+dataMode: DataMode.cloudand assertcreateClient(...)returns anisA<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 ondataModeafter the SP2supabaseClient/supabaseAuthare 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(addlinkAccountToMember) - 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 threadsauth_user_id);StoragePort.getMembers/updateMember;HouseholdMember.copyWith. -
Produces:
Future<HouseholdMember> linkAccountToMember({required String memberId, required String authUserId})onHouseholdService+ theClientfacade — setsauth_user_idon an existing member whoseauthUserIdis null; throwsDomainRuleExceptionif the member already has a differentauthUserId. -
Step 1: Write the failing test (use the in-memory client from
client_sdk_testing): create achildmember with noauthUserId; calllinkAccountToMember; assert the reloaded member has theauthUserIdand that a ledger entry keyed by itsmember_idis unchanged. Second test: linking a member that already has a differentauthUserIdthrowsDomainRuleException. Third test (account-first):addMember(displayName: 'Pat', kind: MemberKind.parent, authUserId: 'a1')persists the link on insert. -
Step 2: Run → FAIL.
-
Step 3: Implement
linkAccountToMemberinHouseholdService:
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(facademigrateLocalHouseholdToCloud) - Test:
packages/client_sdk/test/cloud_migration_test.dart
Interfaces:
- Consumes: a
sourceStoragePort(the local Drift/in-memory store) and atargetSupabaseStorageAdapter;authUserId. - Produces:
CloudMigrationService(StoragePort source, SupabaseStorageAdapter target)withFuture<MigrationResult> upload({required String authUserId}).MigrationResult={bool complete, Map<String,int> sourceCounts, Map<String,int> targetCounts}.
Algorithm (exact):
source.getHousehold(). If null → throwDomainRuleException('Nothing to migrate.').- Read all source members. Pick the owner = first member with
kind == MemberKind.parent. If none → throwDomainRuleException('Household has no parent to own the cloud copy.'). Set the owner'sauthUserId = authUserId(copyWith). - Upload in FK-topological order, preserving ids, via the target's single-row port methods (
target.insertHousehold,target.insertMember, …), wrapping each call so aPostgrestExceptionwith code23505(unique violation = already uploaded) is swallowed as "skip":households(the household row) → owner member (first member — satisfies the bootstrap RLS INSERT clause) → remaining members →places→chores→rewards→activities→activity_gates→budget_categories→goals→chore_submissions→chore_completions→spend_requests→redemptions→approvals→token_batches→entitlements. (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.) - Ledger LAST, special-cased for the zero-floor trigger. The
BEFORE INSERTzero-floor trigger fires even onON 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 bycreatedAtascending, one-by-one viatarget.insertLedgerEntry(chronological order keeps every intermediate balance valid). Never upsert/retry an existing ledger row. - Parity check: for each aggregate, compare
sourcecount vs a freshtargetcount.complete = all equal. Return theMigrationResult.
-
Step 1: Write the failing test — seed an in-memory
source(viaclient_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). Constructtarget = SupabaseStorageAdapter.forTest(MappingPort(FakePostgrest())). Runupload(authUserId: 'a1'); assertresult.completeis true, the owner member in target hasauthUserId == 'a1', and folded target-ledger balances equal source balances. Idempotency test: runuploadtwice → identical counts, no throw. Resume test: set the fake to throw on the Nth insert, runupload(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
CloudMigrationServiceper the algorithm. Provide a private_skipDup(Future<void> Function() op)that runsopand swallowsPostgrestExceptioncode23505. Read source aggregates via thesourceport's getters; for approvals (no list getter) drainsource.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 localsourceand a cloudtarget; 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; projectbgedvvmihygwxhjxlvfu. -
Step 1: Write a guarded live smoke that
skips whenconst 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 throwsInsufficientBalanceException. Tag the testtags: ['live']. -
Step 2: Run the smoke with creds — the 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 addthe 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.