Skip to main content

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 to tail/grep to 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 Drift local_database.g.dart when the schema changes). After ANY Drift regen, git diff --stat and restore clobbered siblings (git checkout HEAD -- <path>) — --delete-conflicting-outputs is known to wipe hand-maintained .g.dart / router.gr.dart / bloc state.g.dart.
  • Drift: schemaVersion => 11 (currently 10); add exactly one if (from < 11) { … } block in onUpgrade (packages/client_sdk/lib/src/adapters/local/local_database.dart). Maps/lists are JSON text columns — mirror subtasks: TextColumn get x => text().withDefault(const Constant('{}'))(); (or '[]' for lists).
  • Supabase migrations: new files infra/supabase/migrations/20260612000012_*.sql … (continue the numbering after …011), with CHECK constraints; RLS unchanged (same member_household_ids() scoping). The AI models get NO migration.
  • Models: @JsonSerializable(fieldRename: FieldRename.snake) (+ explicitToJson: true where nested, like Chore); copyWith uses the nullable-setter idiom (String? Function()? setX for nullable fields, plain T? for non-nullable). Add every new field to props.
  • Invariant-1 amendment (approved): a temp-bonus pays on an expectationtokenValue stays 0, but effectiveTokens = 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/; run cd 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 goal isActive).

Verified anchors

  • local_database.dart: schemaVersion => 10 (line ~520); onUpgrade if (from < N) blocks; chores cols at ~130-165 (subtasks JSON at 135, approvalPolicy at 140, weeklyDays at 150, placeId at 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 params this.homePlaceId, this.watchOnly = false; final String? homePlaceId; final bool watchOnly;; copyWith String? Function()? setHomePlaceId, bool? watchOnly (apply setHomePlaceId != null ? setHomePlaceId() : homePlaceId, watchOnly ?? this.watchOnly); add both to props.
  • 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 the HouseholdMembers table add TextColumn get homePlaceId => text().nullable().references(Places, #id, onDelete: KeyAction.setNull)(); and BoolColumn get watchOnly => boolean().withDefault(const Constant(false))();; bump schemaVersion => 11; add if (from < 11) { await m.addColumn(householdMembers, householdMembers.homePlaceId); await m.addColumn(householdMembers, householdMembers.watchOnly); /* (C2-C5 columns appended in their tasks) */ }. Regen local_database.g.dart (scoped --build-filter "packages/client_sdk/lib/src/adapters/local/local_database.g.dart"), restore siblings.
    • local_storage_adapter.dart: map homePlaceId/watchOnly in the HouseholdMember↔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 a homePlaceId; add a watchOnly infant to demo_seed.dart.
  • Step 7 — failing service test (household_service_test.dart): setMemberHomePlace rejects a placeId not in the household; assignableMembers excludes watchOnly. Run → FAIL.
  • Step 8 — implement HouseholdService.setMemberHomePlace (validate the place belongs to the member's household, then updateMember(member.copyWith(setHomePlaceId: () => placeId))) + an assignableMembers/activeMembers getter 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 {} to Chore (+ copyWith List<String>? assignedMemberIds, Map<String,List<String>>? roomAssignees, props). Add getter List<String> get assignedMemberIdsUnion => {...assignedMemberIds, for (final v in roomAssignees.values) ...v}.toList();. Regen chore.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 the if (from < 11) block with their addColumns; map both (JSON) in local_storage_adapter.dart. Migration 20260612000013_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: in createChore/updateChore, validate roomAssignees keys are existing places of the household; compute assignedMemberIds as the union (when roomAssignees provided, the stored assignedMemberIds = union). Add List<String> membersForRoom(Chore c, String placeId, {required List<HouseholdMember> members}) (explicit roomAssignees[placeId] → else members with homePlaceId == placeId among assignedMemberIdsUnion → else assignedMemberIdsUnion).
  • 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 the stepsForMember method (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 (nested Subtask lists) in the adapter. Migration 20260612000014_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 — TempBonusUntil enum (today/thisWeek/untilOff, with wireName/fromWireName like ChoreFrequency); add multiPerDay to ChoreFrequency; add the four Chore fields + hasTempBonus/effectiveTokens(asOf) (active iff bonus set AND not past the until-window). Regen both .g scoped, 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. Migration 20260612000015_*.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 for multiPerDay in _guardNotDoneInCurrentPeriod/submitCompletion (count today's completions/submissions ≤ maxPerDay); add sweepExpiredTempBonuses(DateTime asOf) (null out tempBonusTokens/tempBonusUntil past the window, append-only update). In the token-award path (ApprovalService/EconomyService where tokenAmount is set from chore.tokenValue), use chore.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: Goal with status: requestedisActive == true; status: archivedisActive == false; json key status; requestGoal→requested, approveGoal→active, completeGoal→complete, archiveGoal→archived. Run → FAIL.
  • Step 2 — GoalStatus enum (wireName/fromWireName) + edit Goal: replace the stored isActive field with GoalStatus status (default active), add imageUrl, dueLabel, and bool get isActive => status != GoalStatus.archived. Update copyWith (GoalStatus? status, String? Function()? setImageUrl, String? Function()? setDueLabel) + props (status, imageUrl, dueLabel — drop isActive). 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/dueLabel nullable text; in the v11 block, addColumn(status/imageUrl/dueLabel) then customStatement("UPDATE goals SET status = CASE WHEN is_active THEN 'active' ELSE 'archived' END") (Drift keeps the legacy is_active column; the model no longer reads it). Adapter maps status/imageUrl/dueLabel. Migration 20260612000016_*.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, make archiveGoal set status = archived (keep behavior); add requestGoal (→ requested; kid-scoped), approveGoal (→ active; parental gate, mirror approval validation), completeGoal (→ complete). getGoals default filter stays "not archived" via the isActive getter.
  • Step 5 — verify app compiles: cd app && fvm flutter analyze > /tmp/a.txt 2>&1; echo "EXIT=$?" → no errors from goal.isActive reads. If a copyWith(isActive:) caller exists, migrate it to setStatus/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/fromJson round-trip equality (nested list); recommend() returns an empty recommendation. Run → FAIL.
  • Step 2 — models: two @JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true) Equatable classes (no householdId, 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 the Client interface + ClientImpl returning 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 subtasks JSON pattern.
  • Consistency: assignedMemberIds/roomAssignees/assignedMemberIdsUnion, stepsForMember, effectiveTokens(asOf)/TempBonusUntil, GoalStatus/isActive getter, ChoreRecommendation/ChoreSuggestion/recommend() used consistently across tasks. The single if (from < 11) Drift block is extended cumulatively by T1-T5 (note: each task appends its columns to the same block + bumps nothing further — schemaVersion is set to 11 once in T1).
  • Cross-task note for the executor: schemaVersion => 11 is set in T1; T2-T5 only ADD columns into the existing if (from < 11) block (do not create new version blocks).