SDK Domain Completion 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.
Goal: Close all 11 North-Star/POC domain gaps in client_sdk — behaviorally complete, SDK-only — so the cloud schema (sub-projects 2–4) is built once against the full domain.
Architecture: Six entity clusters, each carrying the full layer stack and ending green: model (+@JsonSerializable codegen) → Drift column + if (from < 11) migration (schemaVersion 10→11) → Supabase migration (…000012+) → in-memory adapter mapping + client_sdk_testing seed factories → SDK service rule → unit tests. Follows the approvalPolicy precedent (Drift v3 / Supabase …000003).
Tech Stack: Dart 3.9 (pure-Dart client_sdk), Drift (local), Supabase SQL (cloud), json_serializable, FVM. NO Flutter UI, NO Node.
Global Constraints
- FVM for everything:
fvm dart run …,fvm flutter test …. Run from the repo root unless noted. - TRUE exit codes:
<cmd> > /tmp/t.txt 2>&1; echo "EXIT=$?"then inspect the file. Never pipe a test/codegen run totail/grepto judge pass/fail. - Scoped codegen:
fvm dart run build_runner build --delete-conflicting-outputs --build-filter "packages/client_sdk/lib/src/models/<file>.g.dart"(and the Driftlocal_database.g.dartwhen the schema changes). After ANY Drift regen,git diff --statand restore clobbered siblings (git checkout HEAD -- <path>) —--delete-conflicting-outputsis known to wipe hand-maintained.g.dart/router.gr.dart/ blocstate.g.dart. - Drift:
schemaVersion => 11(currently 10); add exactly oneif (from < 11) { … }block inonUpgrade(packages/client_sdk/lib/src/adapters/local/local_database.dart). Maps/lists are JSON text columns — mirrorsubtasks:TextColumn get x => text().withDefault(const Constant('{}'))();(or'[]'for lists). - Supabase migrations: new files
infra/supabase/migrations/20260612000012_*.sql… (continue the numbering after…011), withCHECKconstraints; RLS unchanged (samemember_household_ids()scoping). The AI models get NO migration. - Models:
@JsonSerializable(fieldRename: FieldRename.snake)(+explicitToJson: truewhere nested, likeChore);copyWithuses the nullable-setter idiom (String? Function()? setXfor nullable fields, plainT?for non-nullable). Add every new field toprops. - Invariant-1 amendment (approved): a temp-bonus pays on an expectation —
tokenValuestays 0, buteffectiveTokens = tokenValue + activeTempBonus. Document this in the economy invariants comment + the C4 service code. - Commit per task on branch
feat/sdk-domain-completion; do NOT push. - Tests: unit tests under
packages/client_sdk/test/; runcd packages/client_sdk && fvm dart test(pure-Dart package). Keep the app flow tests green (cd app && fvm flutter test) only when a cluster could affect them (C5 goalisActive).
Verified anchors
local_database.dart:schemaVersion => 10(line ~520);onUpgradeif (from < N)blocks; chores cols at ~130-165 (subtasksJSON at 135,approvalPolicyat 140,weeklyDaysat 150,placeIdat 165 with FK).chore_service.dart:createChore(66),updateChore(108),claimBounty(143),submitCompletion(202),isChoreDoneInCurrentPeriod(279),_guardNotDoneInCurrentPeriod(301),_validateApprovalPolicy(396).economy_service.dart:createGoal(473),getGoals(513),archiveGoal(526),restoreGoal(532),updateGoal(552),goalProgress(580).- Test doubles:
packages/client_sdk_testing/lib/src/{seed_factories,demo_seed,create_in_memory_client,mock_client}.dart.
Task 1 (C1): HouseholdMember — homePlaceId + watchOnly
Files:
- Modify:
packages/client_sdk/lib/src/models/household_member.dart(+ regen.g.dart) - Modify:
packages/client_sdk/lib/src/adapters/local/local_database.dart(columns + v11 block) - Modify:
packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart(row↔model mapping) - Modify:
packages/client_sdk/lib/src/services/household_service.dart(validate homePlaceId; watchOnly helper) - Create:
infra/supabase/migrations/20260612000012_member_home_room_watch_only.sql - Modify:
packages/client_sdk_testing/lib/src/seed_factories.dart - Test:
packages/client_sdk/test/models/household_member_test.dart,…/services/household_service_test.dart
Interfaces — Produces: HouseholdMember.homePlaceId (String?), HouseholdMember.watchOnly (bool, default false); HouseholdService.setMemberHomePlace({required String memberId, required String? placeId}); an activeMembers/assignableMembers filter that excludes watchOnly.
- Step 1 — failing model test (
household_member_test.dart):
test('homePlaceId + watchOnly round-trip through json', () {
final m = HouseholdMember(id: 'm1', householdId: 'h1', displayName: 'Ada', kind: MemberKind.child, homePlaceId: 'p1', watchOnly: true);
final j = m.toJson();
expect(j['home_place_id'], 'p1');
expect(j['watch_only'], true);
expect(HouseholdMember.fromJson(j), m);
});
test('watchOnly defaults to false', () {
expect(HouseholdMember(id: 'm', householdId: 'h', displayName: 'x', kind: MemberKind.child).watchOnly, isFalse);
});
- Step 2 — run, expect FAIL:
cd packages/client_sdk && fvm dart test test/models/household_member_test.dart > /tmp/t.txt 2>&1; echo "EXIT=$?"→ FAIL (no such field). - Step 3 — add the fields to
household_member.dart: constructor paramsthis.homePlaceId, this.watchOnly = false;final String? homePlaceId; final bool watchOnly;; copyWithString? Function()? setHomePlaceId, bool? watchOnly(applysetHomePlaceId != null ? setHomePlaceId() : homePlaceId,watchOnly ?? this.watchOnly); add both toprops. - Step 4 — regen + restore siblings:
fvm dart run build_runner build --delete-conflicting-outputs --build-filter "packages/client_sdk/lib/src/models/household_member.g.dart" > /tmp/g.txt 2>&1; echo "EXIT=$?"
git diff --stat # only household_member.g.dart should change; restore any clobbered sibling
- Step 5 — run model test, expect PASS.
- Step 6 — Drift + Supabase + adapter + seed:
local_database.dart: in theHouseholdMemberstable addTextColumn get homePlaceId => text().nullable().references(Places, #id, onDelete: KeyAction.setNull)();andBoolColumn get watchOnly => boolean().withDefault(const Constant(false))();; bumpschemaVersion => 11; addif (from < 11) { await m.addColumn(householdMembers, householdMembers.homePlaceId); await m.addColumn(householdMembers, householdMembers.watchOnly); /* (C2-C5 columns appended in their tasks) */ }. Regenlocal_database.g.dart(scoped--build-filter "packages/client_sdk/lib/src/adapters/local/local_database.g.dart"), restore siblings.local_storage_adapter.dart: maphomePlaceId/watchOnlyin theHouseholdMember↔row functions (mirror existing fields).20260612000012_member_home_room_watch_only.sql:
ALTER TABLE household_members
ADD COLUMN home_place_id text REFERENCES places(id) ON DELETE SET NULL,
ADD COLUMN watch_only boolean NOT NULL DEFAULT false;
seed_factories.dart: give one seeded kid ahomePlaceId; add awatchOnlyinfant todemo_seed.dart.- Step 7 — failing service test (
household_service_test.dart):setMemberHomePlacerejects aplaceIdnot in the household;assignableMembersexcludeswatchOnly. Run → FAIL. - Step 8 — implement
HouseholdService.setMemberHomePlace(validate the place belongs to the member's household, thenupdateMember(member.copyWith(setHomePlaceId: () => placeId))) + anassignableMembers/activeMembersgetter filtering!watchOnly(and reuse it wherever services enumerate actors). - Step 9 — run full client_sdk suite, expect PASS:
cd packages/client_sdk && fvm dart test > /tmp/t.txt 2>&1; echo "EXIT=$?". - Step 10 — commit:
git add packages/client_sdk packages/client_sdk_testing infra/supabase/migrations && git commit -m "feat(sdk): HouseholdMember home-room + watch-only (C1)"
Task 2 (C2): Chore — assignedMemberIds + roomAssignees
Files: models/chore.dart (+.g); local_database.dart (cols + extend v11 block); local_storage_adapter.dart; services/chore_service.dart; infra/supabase/migrations/20260612000013_chore_assignment_rooms.sql; client_sdk_testing/seed_factories.dart; tests test/models/chore_test.dart, test/services/chore_service_assignment_test.dart.
Interfaces — Consumes: C1's watchOnly/assignableMembers. Produces: Chore.assignedMemberIds (List<String>, default [], empty = all eligible), Chore.roomAssignees (Map<String,List<String>>, default {}); Chore.assignedMemberIdsUnion getter; ChoreService.setChoreAssignees + membersForRoom(chore, placeId).
- Step 1 — failing tests (
chore_service_assignment_test.dart):
test('assignedMemberIds is the union of roomAssignees on create/update', () async {
final c = await svc.createChore(name: 'Clean room', kind: ChoreKind.expectation, frequency: ChoreFrequency.weekly,
roomAssignees: {'p1': ['m1'], 'p2': ['m2','m1']});
expect(c.assignedMemberIds.toSet(), {'m1','m2'});
});
test('membersForRoom: explicit list wins, else homePlaceId residents, else all assignees', () {
final c = Chore(id:'c', householdId:'h', name:'x', kind:ChoreKind.expectation, frequency:ChoreFrequency.daily,
assignedMemberIds:['m1','m2'], roomAssignees:{'p1':['m1']});
expect(svc.membersForRoom(c, 'p1', members: members), ['m1']);
});
test('roomAssignees keys must be valid places', () async {
expect(() => svc.createChore(name:'x', kind:ChoreKind.expectation, frequency:ChoreFrequency.daily, roomAssignees:{'nope':['m1']}), throwsA(isA<DomainRuleException>()));
});
- Step 2 — run, expect FAIL.
- Step 3 — model: add
assignedMemberIds = const [],roomAssignees = const {}toChore(+ copyWithList<String>? assignedMemberIds, Map<String,List<String>>? roomAssignees, props). Add getterList<String> get assignedMemberIdsUnion => {...assignedMemberIds, for (final v in roomAssignees.values) ...v}.toList();. Regenchore.g.dart(scoped), restore siblings, run model round-trip test → PASS. - Step 4 — Drift + Supabase + adapter: add
TextColumn get assignedMemberIds => text().withDefault(const Constant('[]'))();+TextColumn get roomAssignees => text().withDefault(const Constant('{}'))();to the chores table; extend theif (from < 11)block with theiraddColumns; map both (JSON) inlocal_storage_adapter.dart. Migration20260612000013_chore_assignment_rooms.sql:
ALTER TABLE chores
ADD COLUMN assigned_member_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN room_assignees jsonb NOT NULL DEFAULT '{}'::jsonb;
- Step 5 — service rules in
chore_service.dart: increateChore/updateChore, validateroomAssigneeskeys are existing places of the household; computeassignedMemberIdsas the union (when roomAssignees provided, the storedassignedMemberIds= union). AddList<String> membersForRoom(Chore c, String placeId, {required List<HouseholdMember> members})(explicitroomAssignees[placeId]→ else members withhomePlaceId == placeIdamongassignedMemberIdsUnion→ elseassignedMemberIdsUnion). - Step 6 — run client_sdk suite, expect PASS. Step 7 — commit:
feat(sdk): Chore assignment + per-room assignees (C2).
Task 3 (C3): Chore — per-member step overrides (closes #206)
Files: models/chore.dart (+.g); local_database.dart; local_storage_adapter.dart; infra/supabase/migrations/20260612000014_chore_steps_per_member.sql; seed; test test/models/chore_steps_test.dart.
Interfaces — Produces: Chore.stepsPerMember (Map<String,List<Subtask>>, default {}) + List<Subtask> stepsForMember(String memberId) (override if present, else subtasks).
- Step 1 — failing test:
test('stepsForMember returns the override, else the shared subtasks', () {
final shared = [Subtask(id:'s1', name:'all')];
final c = Chore(id:'c', householdId:'h', name:'x', kind:ChoreKind.expectation, frequency:ChoreFrequency.daily,
subtasks: shared, stepsPerMember: {'m1': [Subtask(id:'s2', name:'easy')]});
expect(c.stepsForMember('m1').single.name, 'easy');
expect(c.stepsForMember('m2'), shared);
});
test('stepsPerMember round-trips through json', () { /* toJson/fromJson equality */ });
- Step 2 — run, FAIL. Step 3 — model: add
stepsPerMember = const {}(+ copyWith, props) and thestepsForMembermethod (stepsPerMember[memberId] ?? subtasks). Regen scoped, restore siblings, run → PASS. - Step 4 — Drift + Supabase + adapter:
TextColumn get stepsPerMember => text().withDefault(const Constant('{}'))();; extend v11 block; map JSON (nestedSubtasklists) in the adapter. Migration20260612000014_chore_steps_per_member.sql:ALTER TABLE chores ADD COLUMN steps_per_member jsonb NOT NULL DEFAULT '{}'::jsonb; - Step 5 — run suite, PASS. Step 6 — commit:
feat(sdk): Chore per-member step overrides (C3, closes #206).
Task 4 (C4): Chore — recurrence & incentives
Files: models/chore.dart (+.g — incl. ChoreFrequency.multiPerDay); models/temp_bonus_until.dart (new enum, +.g); local_database.dart; local_storage_adapter.dart; services/chore_service.dart; services/economy_service.dart (effectiveTokens at award); infra/supabase/migrations/20260612000015_chore_recurrence_incentives.sql; seed; tests test/services/chore_recurrence_test.dart, test/models/chore_temp_bonus_test.dart.
Interfaces — Produces: ChoreFrequency.multiPerDay; Chore.maxPerDay (int?), Chore.estimateMin (int?), Chore.tempBonusTokens (int?), Chore.tempBonusUntil (TempBonusUntil today|thisWeek|untilOff); computed bool get hasTempBonus, int effectiveTokens(DateTime asOf); ChoreService.sweepExpiredTempBonuses(DateTime asOf); the per-day cap guard.
- Step 1 — failing tests:
test('effectiveTokens adds an active temp-bonus, incl. on an expectation', () {
final c = Chore(id:'c', householdId:'h', name:'x', kind:ChoreKind.expectation, frequency:ChoreFrequency.daily,
tokenValue: 0, tempBonusTokens: 5, tempBonusUntil: TempBonusUntil.untilOff);
expect(c.effectiveTokens(DateTime(2026,6,22)), 5); // invariant-1 amendment: tokenValue stays 0
});
test('multiPerDay cap rejects a submission past maxPerDay', () async { /* claim+complete maxPerDay times, next throws */ });
test('sweepExpiredTempBonuses clears a today-scoped bonus on a later day', () async { /* set until=today, sweep next day, expect tempBonusTokens null */ });
- Step 2 — run, FAIL. Step 3 —
TempBonusUntilenum (today/thisWeek/untilOff, withwireName/fromWireNamelikeChoreFrequency); addmultiPerDaytoChoreFrequency; add the fourChorefields +hasTempBonus/effectiveTokens(asOf)(active iff bonus set AND not past the until-window). Regen both.gscoped, restore siblings, run model test → PASS. - Step 4 — Drift + Supabase + adapter:
IntColumn get maxPerDay => integer().nullable()();,IntColumn get estimateMin => integer().nullable()();,IntColumn get tempBonusTokens => integer().nullable()();,TextColumn get tempBonusUntil => text().nullable()();; extend v11 block; map in adapter. Migration20260612000015_*.sql:
ALTER TABLE chores
ADD COLUMN max_per_day integer CHECK (max_per_day IS NULL OR max_per_day >= 0),
ADD COLUMN estimate_min integer CHECK (estimate_min IS NULL OR estimate_min >= 0),
ADD COLUMN temp_bonus_tokens integer CHECK (temp_bonus_tokens IS NULL OR temp_bonus_tokens >= 0),
ADD COLUMN temp_bonus_until text CHECK (temp_bonus_until IS NULL OR temp_bonus_until IN ('today','this_week','until_off'));
ALTER TABLE chores DROP CONSTRAINT IF EXISTS chores_frequency_check;
ALTER TABLE chores ADD CONSTRAINT chores_frequency_check CHECK (frequency IN ('once','daily','weekly','monthly','multi_per_day'));
- Step 5 — service rules: in
chore_service.dart, enforce the per-day cap formultiPerDayin_guardNotDoneInCurrentPeriod/submitCompletion(count today's completions/submissions ≤maxPerDay); addsweepExpiredTempBonuses(DateTime asOf)(null outtempBonusTokens/tempBonusUntilpast the window, append-only update). In the token-award path (ApprovalService/EconomyServicewheretokenAmountis set fromchore.tokenValue), usechore.effectiveTokens(now)so an active bonus pays — document the invariant-1 amendment in the economy invariants comment. - Step 6 — run suite, PASS. Step 7 — commit:
feat(sdk): Chore multiPerDay + maxPerDay + estimateMin + temp-bonus (C4).
Task 5 (C5): Goal — lifecycle & media
Files: models/goal.dart (+.g); models/goal_status.dart (new enum, +.g); local_database.dart; local_storage_adapter.dart; services/economy_service.dart; infra/supabase/migrations/20260612000016_goal_lifecycle_media.sql; seed; tests test/models/goal_test.dart, test/services/economy_goal_lifecycle_test.dart. Also touch any app read of goal.isActive (it keeps compiling via the getter — verify with cd app && fvm flutter analyze).
Interfaces — Produces: GoalStatus { active, requested, complete, archived }; Goal.status, Goal.imageUrl (String?), Goal.dueLabel (String?); bool get isActive => status != GoalStatus.archived; EconomyService.requestGoal/approveGoal/completeGoal/archiveGoal.
- Step 1 — failing tests:
Goalwithstatus: requested→isActive == true;status: archived→isActive == false; json keystatus;requestGoal→requested,approveGoal→active,completeGoal→complete,archiveGoal→archived. Run → FAIL. - Step 2 —
GoalStatusenum (wireName/fromWireName) + editGoal: replace the storedisActivefield withGoalStatus status(defaultactive), addimageUrl,dueLabel, andbool get isActive => status != GoalStatus.archived. UpdatecopyWith(GoalStatus? status, String? Function()? setImageUrl, String? Function()? setDueLabel) +props(status, imageUrl, dueLabel— dropisActive). Regen scoped, restore siblings, run model test → PASS. - Step 3 — Drift + Supabase + adapter (with backfill): rename column intent — add
TextColumn get status => text().withDefault(const Constant('active'))();+imageUrl/dueLabelnullable text; in the v11 block,addColumn(status/imageUrl/dueLabel)thencustomStatement("UPDATE goals SET status = CASE WHEN is_active THEN 'active' ELSE 'archived' END")(Drift keeps the legacyis_activecolumn; the model no longer reads it). Adapter mapsstatus/imageUrl/dueLabel. Migration20260612000016_*.sql:
ALTER TABLE goals ADD COLUMN status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','requested','complete','archived'));
ALTER TABLE goals ADD COLUMN image_url text;
ALTER TABLE goals ADD COLUMN due_label text;
UPDATE goals SET status = CASE WHEN is_active THEN 'active' ELSE 'archived' END;
ALTER TABLE goals DROP COLUMN is_active;
- Step 4 — service: in
economy_service.dart, makearchiveGoalsetstatus = archived(keep behavior); addrequestGoal(→ requested; kid-scoped),approveGoal(→ active; parental gate, mirror approval validation),completeGoal(→ complete).getGoalsdefault filter stays "not archived" via theisActivegetter. - Step 5 — verify app compiles:
cd app && fvm flutter analyze > /tmp/a.txt 2>&1; echo "EXIT=$?"→ no errors fromgoal.isActivereads. If acopyWith(isActive:)caller exists, migrate it tosetStatus/the new transition method. - Step 6 — run client_sdk suite, PASS. Step 7 — commit:
feat(sdk): Goal lifecycle (status) + media (C5).
Task 6 (C6): AI recommender models (no persistence)
Files: Create models/chore_recommendation.dart, models/chore_suggestion.dart (+.g); modify the client barrel/exports; client/client.dart + client_impl.dart (recommend() stub); test test/models/chore_recommendation_test.dart.
Interfaces — Produces: ChoreSuggestion { String title; String? emoji; String typeName; int tokens; String reason; String? placeId; String? matchesExistingId }; ChoreRecommendation { String balanceNote; List<ChoreSuggestion> suggestions }; Client.recommend() → Future<ChoreRecommendation> (stub returns ChoreRecommendation(balanceNote: '', suggestions: const [])).
- Step 1 — failing test: construct both,
toJson/fromJsonround-trip equality (nested list);recommend()returns an empty recommendation. Run → FAIL. - Step 2 — models: two
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)Equatable classes (nohouseholdId, no table). Export from the package barrel. Regen scoped (chore_recommendation.g.dart,chore_suggestion.g.dart), restore siblings. - Step 3 — facade stub: add
Future<ChoreRecommendation> recommend()to theClientinterface +ClientImplreturning the empty recommendation (the real recommender is a later sub-project). Run test → PASS. - Step 4 — full green + final regen check:
cd packages/client_sdk && fvm dart test > /tmp/t.txt 2>&1; echo "SDK_EXIT=$?"
cd ../../app && fvm flutter test > /tmp/a.txt 2>&1; echo "APP_EXIT=$?"
graphify update . # keep the graph current after the model changes
Expected: SDK_EXIT=0, APP_EXIT=0.
- Step 5 — commit:
feat(sdk): ChoreRecommendation/ChoreSuggestion models + recommend() stub (C6).
Self-review
- Spec coverage: C1 member home-room/watch-only ✓ (T1); C2 assignment+rooms+union invariant ✓ (T2); C3 per-member steps/#206 ✓ (T3); C4 multiPerDay/maxPerDay/estimateMin/temp-bonus + invariant-1 amendment ✓ (T4); C5 goal lifecycle+media+isActive getter+backfill ✓ (T5); C6 AI models+recommend stub ✓ (T6). Drift v10→11 single block (woven across T1-T5), Supabase
…012-016, RLS unchanged, AI no table — all covered. - Placeholder scan: field decls, migration SQL, service-rule descriptions, and representative tests are concrete; codegen + test commands exact with TRUE-exit-code capture. The maps follow the verified
subtasksJSON pattern. - Consistency:
assignedMemberIds/roomAssignees/assignedMemberIdsUnion,stepsForMember,effectiveTokens(asOf)/TempBonusUntil,GoalStatus/isActivegetter,ChoreRecommendation/ChoreSuggestion/recommend()used consistently across tasks. The singleif (from < 11)Drift block is extended cumulatively by T1-T5 (note: each task appends its columns to the same block + bumps nothing further —schemaVersionis set to 11 once in T1). - Cross-task note for the executor:
schemaVersion => 11is set in T1; T2-T5 only ADD columns into the existingif (from < 11)block (do not create new version blocks).