Skip to main content

Rooms — Ownership + Chore Multi-Room + Multi-Select Picker Implementation Plan

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

Goal: Make rooms first-class owned spaces — a room has persistent multi-owner ownership, a chore is tagged to a set of rooms, and who does a chore in a room resolves manual-override → room-owner(s) → chore-assignees, all driven by one reusable multi-select room picker.

Architecture: SDK-up. Add Place.ownerIds (additive), then replace Chore.placeId with Chore.roomIds as ONE compile unit through the model + StoragePort + all 5 adapters + ChoreService, and rewrite ChoreService.membersForRoom to the new 3-tier rule (override → place.ownerIdschore.assignedMemberIds). Build one DS multi-select room picker and rewire the chore editor + Rooms/Household + Today/printables onto it. Close with a file-only migration and gadfly-canonical flow tests.

Tech Stack: Flutter 3.44 / Dart 3.9 (FVM: fvm flutter ...), flutter_bloc, Drift + Supabase adapters, json_serializable, Equatable, flutter_test + the flow_test harness.

Global Constraints

  • One data path: Bloc/Cubit → Repository → Client facade → Service → Adapter. Presentation (app/lib/inside/**) imports ONLY the client_sdk facade + app repositories — never drift/supabase/any I/O.
  • Typed errors only: on <SpecificException>; never bare catch, never catch Error. Domain rules throw DomainRuleException / ValidationException in the service.
  • Chore.placeId → roomIds is ONE compile unit: the barrel re-exports the service + all 5 adapters, so the rename must land through model + StoragePort + 5 adapters + ChoreService in a single task or the package will not compile.
  • ownerIds / roomIds must round-trip across all 5 adapters: in-memory, FakePort, cloud/Supabase, local/Drift, cached write-through.
  • Migration is file-only under infra/supabase/migrations/; prod apply is DEPLOY-GATED (owner-gated). places.owner_ids uuid[] default '{}'; chores.room_ids uuid[] backfilled from place_id.
  • Naming (owner standard): NEW app classes/files use the gadfly underscore {Name}_Suffix convention (docs/coding_guidelines/naming_conventions.md) — this is a fresh build, not in-flight legacy. EXCEPTIONS: models/entities are plain PascalCase (Place, Chore); DS components are Ds-prefixed PascalCase (DsMultiRoomPicker); files are always snake_case. When MODIFYING an existing PascalCase legacy widget/bloc in place, leave its name; only NEW extracted units take underscore.
  • Resolution (single source of truth = ChoreService.membersForRoom): (a) non-empty chore.roomAssignees[roomId](b) non-empty place.ownerIds(c) chore.assignedMemberIds. This REPLACES the current Tier-2 (member.homePlaceId) with room ownership.
  • v1 completion stays per-chore (one chore = one completion). Per-room completion is explicitly out of scope.
  • TDD; frequent commits; flow tests gadfly-canonical (ONE flowTest, multiple stories); baselines must not drop.

File Structure

SDK (packages/client_sdk/)

  • lib/src/models/place.dart — add ownerIds: List<String> (+ copyWith/props/json).
  • lib/src/models/chore.dart — replace placeId with roomIds: List<String>; update assignedMemberIdsUnion consumers, copyWith, props, json, and the placeId→roomIds fromJson migration.
  • lib/src/adapters/adapter.dart (StoragePort) + the 5 adapters (memory/, client_sdk_testing FakePort, cloud/supabase_catalog.dart places, cloud/supabase_chores.dart chores, local/local_database.dart+.g.dart+local_storage_adapter.dart, cached/cached_storage_adapter.dart) — carry owner_ids / room_ids.
  • lib/src/services/chore_service.dart — new membersForRoom(chore, roomId, {place}) + assignedMemberIdsUnion/print-list callers iterate roomIds.
  • lib/src/client/client.dart + client_impl.dart — facade signature for membersForRoom.

DS (packages/design_system/)

  • lib/src/molecules/ds_multi_room_picker.dart — the reusable multi-select, floor-grouped room picker + showDsMultiRoomPicker.

App (app/lib/inside/)

  • routes/authenticated/chore_editor/widgets/chore_editor_body.dart — multi-room tagging + per-room override + resolved-doers readout.
  • routes/authenticated/rooms/page.dart + a new Rooms_Card_OwnerEditor widget — room-owner editor.
  • routes/authenticated/home/** Today by-room grouping + RoomPrintDialog — use the resolver + iterate roomIds.

Infrainfra/supabase/migrations/20260726000100_rooms_ownership_multiroom.sql.

Tests — SDK unit (test/services/chore_service_members_for_room_test.dart, adapter round-trip), DS golden (test/molecules/ds_multi_room_picker_test.dart), app flow (app/test/flows/rooms_ownership_test.dart).


Task 1: SDK — Place.ownerIds (additive)

Files:

  • Modify: packages/client_sdk/lib/src/models/place.dart
  • Test: packages/client_sdk/test/models/place_test.dart

Interfaces — Produces: Place.ownerIds: List<String> (default const []), Place.copyWith({List<String>? ownerIds}), JSON key owner_ids.

  • Step 1: Write the failing test
// packages/client_sdk/test/models/place_test.dart
import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
test('Place carries ownerIds through json + copyWith + props', () {
const p = Place(
id: 'r1', householdId: 'h1', houseId: 'ho1', name: 'Kitchen',
ownerIds: ['m1', 'm2'],
);
expect(p.ownerIds, ['m1', 'm2']);
expect(p.toJson()['owner_ids'], ['m1', 'm2']);
expect(Place.fromJson(p.toJson()).ownerIds, ['m1', 'm2']);
expect(p.copyWith(ownerIds: ['m3']).ownerIds, ['m3']);
const bare = Place(id: 'r1', householdId: 'h1', houseId: 'ho1', name: 'Kitchen');
expect(bare.ownerIds, isEmpty);
expect(p == bare, isFalse);
});

test('Place.fromJson defaults ownerIds to empty when key absent', () {
final p = Place.fromJson({
'id': 'r1', 'household_id': 'h1', 'house_id': 'ho1', 'name': 'Kitchen',
});
expect(p.ownerIds, isEmpty);
});
}
  • Step 2: Run — expect FAIL (ownerIds undefined)

Run: cd packages/client_sdk && fvm flutter test test/models/place_test.dart Expected: FAIL — The named parameter 'ownerIds' isn't defined.

  • Step 3: Implement

Edit place.dart: add the field with a JSON default, thread it through the constructor, copyWith, props, and regenerate place.g.dart.

// in the constructor param list, after houseId/name/floor:
this.ownerIds = const <String>[],
// field, with a null-safe json default for rows written before this column:
/// Member ids that OWN this room (spec: persistent, household-scoped, multi-
/// owner). Drives chore-doer resolution (ChoreService.membersForRoom tier 2).
/// Wire twin `places.owner_ids uuid[]` default `'{}'`.
@JsonKey(defaultValue: <String>[])
final List<String> ownerIds;
// in copyWith params + body:
List<String>? ownerIds,
// ...
ownerIds: ownerIds ?? this.ownerIds,
// in props: add ownerIds
List<Object?> get props => [id, householdId, houseId, name, floor, ownerIds, createdAt];

Regenerate the serializer (scoped, then restore any clobbered sibling .g.dart per the Drift-regen rule):

cd packages/client_sdk && fvm flutter pub run build_runner build --delete-conflicting-outputs \
--build-filter "lib/src/models/place.g.dart"
git checkout HEAD -- $(git diff --name-only -- '*.g.dart' | grep -v 'lib/src/models/place.g.dart') 2>/dev/null || true
  • Step 4: Run — expect PASS

Run: cd packages/client_sdk && fvm flutter test test/models/place_test.dart Expected: PASS.

  • Step 5: Commit
git add packages/client_sdk/lib/src/models/place.dart packages/client_sdk/lib/src/models/place.g.dart packages/client_sdk/test/models/place_test.dart
git commit -m "feat(sdk): add Place.ownerIds (room ownership)"

Task 2: SDK — Place.ownerIds across the 5 adapters (round-trip)

Files:

  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_catalog.dart (places read/write), .../local/local_database.dart + .g.dart + local_storage_adapter.dart (Drift places table + mapping), .../memory/in_memory_storage_adapter.dart (stores objects — verify), .../cached/cached_storage_adapter.dart (delegates — verify), and packages/client_sdk_testing/lib/src/** FakePort places.
  • Test: packages/client_sdk/test/adapters/place_owner_ids_roundtrip_test.dart

Interfaces — Consumes: Place.ownerIds (Task 1). Produces: every adapter persists + returns ownerIds.

  • Step 1: Write the failing test (in-memory + Drift local round-trip; use the REAL StoragePort place-upsert verb — read adapter.dart for its name)
// packages/client_sdk/test/adapters/place_owner_ids_roundtrip_test.dart
import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk/src/adapters/memory/in_memory_storage_adapter.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
test('InMemory adapter round-trips Place.ownerIds', () async {
final a = InMemoryStorageAdapter();
const place = Place(id: 'r1', householdId: 'h1', houseId: 'ho1',
name: 'Kitchen', ownerIds: ['m1', 'm2']);
await a.upsertPlace(place); // <-- use the real StoragePort verb name
final got = (await a.getPlaces('h1')).single;
expect(got.ownerIds, ['m1', 'm2']);

await a.upsertPlace(place.copyWith(ownerIds: ['m3']));
expect((await a.getPlaces('h1')).single.ownerIds, ['m3']);
});
}
  • Step 2: Run — expect FAIL or ownerIds dropped. Run: cd packages/client_sdk && fvm flutter test test/adapters/place_owner_ids_roundtrip_test.dart. (In-memory stores the object directly, so it may already pass — the Drift path is the real work.)

  • Step 3: Implement — add the owner_ids column to the Drift places table (local_database.dart, reusing the SAME list-JSON converter the codebase already uses for Chore.assignedMemberIds), regenerate local_database.g.dart scoped, map it in local_storage_adapter.dart place read/write, and map owner_ids in supabase_catalog.dart place select/insert/update. Verify in_memory + cached pass through unchanged.

cd packages/client_sdk && fvm flutter pub run build_runner build --delete-conflicting-outputs \
--build-filter "lib/src/adapters/local/local_database.g.dart"
git checkout HEAD -- $(git diff --name-only -- '*.g.dart' | grep -v 'local_database.g.dart') 2>/dev/null || true
  • Step 4: Run — expect PASS (add a Drift-backed round-trip assertion mirroring the existing local-adapter test setup in the repo).

  • Step 5: Commit

git add packages/client_sdk packages/client_sdk_testing
git commit -m "feat(sdk): persist Place.ownerIds across all 5 adapters"

Task 3: SDK — Chore.placeId → roomIds (ONE compile unit) + new resolution

Files:

  • Modify: packages/client_sdk/lib/src/models/chore.dart, .../services/chore_service.dart, .../client/client.dart + client_impl.dart, adapters/adapter.dart (if placeId is in a StoragePort signature), all 5 adapters wherever place_id/placeId is read/written for chores (cloud/supabase_chores.dart, local/local_database.dart+.g.dart+local_storage_adapter.dart, memory/, cached/).
  • Test: packages/client_sdk/test/services/chore_service_members_for_room_test.dart, .../test/models/chore_room_ids_test.dart

Interfaces — Produces:

  • Chore.roomIds: List<String> (default const []) REPLACES Chore.placeId. JSON key room_ids. Chore.fromJson migrates a legacy place_idroomIds: [place_id] when room_ids is absent.

  • Chore.copyWith({List<String>? roomIds}) (drop setPlaceId).

  • ChoreService.membersForRoom(Chore chore, String roomId, {required Place place}) → new 3-tier rule.

  • Facade membersForRoom signature updated to pass the Place.

  • Step 1: Write the failing tests

// chore_room_ids_test.dart — model migration + shape (copy the required chore json
// fields from an existing chore test fixture in the repo).
import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('Chore.fromJson migrates legacy place_id to roomIds', () {
final c = Chore.fromJson({
'id': 'c1', 'household_id': 'h1', 'title': 'Tidy', 'place_id': 'r1',
});
expect(c.roomIds, ['r1']);
});
test('Chore.fromJson prefers room_ids when present', () {
final c = Chore.fromJson({
'id': 'c1', 'household_id': 'h1', 'title': 'Tidy',
'room_ids': ['r1', 'r2'], 'place_id': 'rX',
});
expect(c.roomIds, ['r1', 'r2']);
});
}
// chore_service_members_for_room_test.dart — the 3-tier rule.
// Build ChoreService + the _chore(...) fixture the same way the existing
// chore_service tests do (read one first for the constructor + fixtures).
import 'package:client_sdk/client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const room = Place(id: 'r1', householdId: 'h1', houseId: 'ho1',
name: 'Kitchen', ownerIds: ['owner1']);
group('membersForRoom 3-tier', () {
test('tier 1: explicit roomAssignees override wins', () {
final chore = _chore(roomIds: ['r1'], roomAssignees: {'r1': ['ov1']},
assignedMemberIds: ['a1']);
expect(service.membersForRoom(chore, 'r1', place: room), ['ov1']);
});
test('tier 2: falls back to room owners when no override', () {
final chore = _chore(roomIds: ['r1'], assignedMemberIds: ['a1']);
expect(service.membersForRoom(chore, 'r1', place: room), ['owner1']);
});
test('tier 3: falls back to chore assignees when no override and no owner', () {
final chore = _chore(roomIds: ['r1'], assignedMemberIds: ['a1']);
const ownerless = Place(id: 'r1', householdId: 'h1', houseId: 'ho1', name: 'Kitchen');
expect(service.membersForRoom(chore, 'r1', place: ownerless), ['a1']);
});
});
}
  • Step 2: Run — expect FAIL (compile errors: roomIds/new signature undefined). Run: cd packages/client_sdk && fvm flutter test test/services/chore_service_members_for_room_test.dart test/models/chore_room_ids_test.dart.

  • Step 3: Implement (the whole compile unit)

chore.dart: replace final String? placeId; with @JsonKey(defaultValue: <String>[]) final List<String> roomIds;; wrap the generated factory for the migration:

factory Chore.fromJson(Map<String, dynamic> json) {
if (json['room_ids'] == null && json['place_id'] != null) {
json = {...json, 'room_ids': [json['place_id']]};
}
return _$ChoreFromJson(json);
}

Update copyWith (drop setPlaceId, add roomIds), props (replace placeId with roomIds), and the print-tag doc. Keep roomAssignees + assignedMemberIds unchanged.

chore_service.dart: rewrite membersForRoom to the spec rule and update every caller that iterated placeId (printable lists) to iterate roomIds:

/// Resolves the doers for [roomId] on [chore] (spec 3-tier):
/// (a) chore.roomAssignees[roomId] non-empty → the manual override;
/// (b) place.ownerIds non-empty → the room's owner(s);
/// (c) chore.assignedMemberIds — the chore-level fallback.
List<String> membersForRoom(Chore chore, String roomId, {required Place place}) {
final override = chore.roomAssignees[roomId];
if (override != null && override.isNotEmpty) return override;
if (place.ownerIds.isNotEmpty) return place.ownerIds;
return chore.assignedMemberIds;
}

Thread place_id → room_ids through StoragePort (if present) + all 5 adapters (Drift chores.room_ids column w/ the list converter + regen scoped; Supabase room_ids array; in-memory/cached pass-through) and the facade client.dart/client_impl.dart membersForRoom signature (now takes Place).

cd packages/client_sdk && fvm flutter pub run build_runner build --delete-conflicting-outputs \
--build-filter "lib/src/adapters/local/local_database.g.dart" \
--build-filter "lib/src/models/chore.g.dart"
git checkout HEAD -- $(git diff --name-only -- '*.g.dart' | grep -vE 'local_database.g.dart|models/chore.g.dart') 2>/dev/null || true
  • Step 4: Run — full SDK suite green (the whole package must recompile).

Run: cd packages/client_sdk && fvm flutter test Expected: all pass (the new tests green; no baseline drop).

  • Step 5: Commit
git add packages/client_sdk
git commit -m "feat(sdk): Chore.roomIds replaces placeId; membersForRoom = override->owner->assignees"

Task 4: DS — reusable multi-select room picker

Files:

  • Create: packages/design_system/lib/src/molecules/ds_multi_room_picker.dart (+ export from the DS barrel)
  • Test: packages/design_system/test/molecules/ds_multi_room_picker_test.dart (widget + golden)

Interfaces — Produces: Future<Set<String>?> showDsMultiRoomPicker({required BuildContext context, required List<DsRoomOption> rooms, required Set<String> selected, String? title}) and a DsMultiRoomPicker widget. DsRoomOption({required String id, required String name, String? floor}). Returns the chosen id set (or null if dismissed). Mirrors the modal shape of the existing emoji picker (showDsEmojiPicker/DsEmojiPicker — read it first), floor-grouped via FilterChip, with a "clear" affordance. Test keys: Key('DsMultiRoomPicker.chip.<id>'), Key('DsMultiRoomPicker.done'), Key('DsMultiRoomPicker.clear').

  • Step 1: Write the failing widget test
// ds_multi_room_picker_test.dart
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Set<String>? _lastResult;

void main() {
const rooms = [
DsRoomOption(id: 'r1', name: 'Kitchen', floor: 'Main'),
DsRoomOption(id: 'r2', name: 'Bedroom', floor: 'Upstairs'),
DsRoomOption(id: 'r3', name: 'Garage'),
];

testWidgets('toggling chips builds the selected set; Done returns it',
(tester) async {
await tester.pumpWidget(MaterialApp(
theme: DsTheme.light.toThemeData(),
home: Scaffold(body: Builder(builder: (context) => TextButton(
onPressed: () async {
_lastResult = await showDsMultiRoomPicker(
context: context, rooms: rooms, selected: {'r1'});
},
child: const Text('open')))),
));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('DsMultiRoomPicker.chip.r2')));
await tester.pump();
await tester.tap(find.byKey(const Key('DsMultiRoomPicker.done')));
await tester.pumpAndSettle();
expect(_lastResult, {'r1', 'r2'});
});

testWidgets('clear empties the selection', (tester) async {
// open with {'r1','r2'}, tap DsMultiRoomPicker.clear, Done → {}
});
}
  • Step 2: Run — expect FAIL (showDsMultiRoomPicker undefined). Run: cd packages/design_system && fvm flutter test test/molecules/ds_multi_room_picker_test.dart.

  • Step 3: Implement the widget mirroring DsEmojiPicker's showDsSheet-hosted grid: group rooms by floor (floorless rooms in a trailing "No floor" section — reuse the DS floor-grouping helper if one exists, else group inline), render each as a FilterChip toggling membership in a local Set<String>, a "Clear" text button, and a "Done" primary button that Navigator.pop(context, selected). Copy is caller-supplied (title). No brand strings.

  • Step 4: Run — PASS + author the golden:

cd packages/design_system && fvm flutter test test/molecules/ds_multi_room_picker_test.dart --update-goldens
cd packages/design_system && fvm flutter test test/molecules/ds_multi_room_picker_test.dart

Expected: PASS; commit the golden PNG.

  • Step 5: Commit
git add packages/design_system
git commit -m "feat(ds): DsMultiRoomPicker — reusable floor-grouped multi-select room picker"

Task 5: App — chore editor multi-room + per-room override + resolved doers

Files:

  • Modify: app/lib/inside/routes/authenticated/chore_editor/widgets/chore_editor_body.dart and the chore-editor bloc/state (read them first — they currently hold a single placeId + roomAssignees).
  • Test: extend the existing chore-editor widget/flow tests.

Interfaces — Consumes: Chore.roomIds, ChoreService.membersForRoom (Task 3), showDsMultiRoomPicker (Task 4). Produces: the editor writes roomIds (multi) + optional per-room roomAssignees and shows resolved doers per selected room.

  • Step 1: Write the failing widget test — asserting: opening the room picker and selecting two rooms sets the editor's roomIds to both; a per-room override chip-grid appears only for selected rooms; the read-only resolved-doers line reflects membersForRoom. (Mirror the existing chore-editor test setup; use find.byKey(Key('DsMultiRoomPicker.chip.<id>')).)

  • Step 2: Run — expect FAIL.

  • Step 3: Implement — replace the single RoomPicker usage with a "Rooms this applies to" field opening showDsMultiRoomPicker (seeded from the editor's current roomIds); for each selected room, render the existing per-member override chip grid writing roomAssignees[roomId]; show a read-only "Doers: …" line from membersForRoom(chore, roomId, place: <that place>). Keep the chore-level "Assigned to" as the fallback. NEW extracted widgets use underscore naming (e.g. ChoreEditor_Field_Rooms, ChoreEditor_Text_ResolvedDoers); the existing ChoreEditorBody stays as-is (legacy).

  • Step 4: Run — PASS (chore-editor widget + flow tests green).

  • Step 5: Commit feat(app): chore editor multi-room tagging + per-room override + resolved doers.


Task 6: App — Rooms/Household room-owner editor

Files:

  • Modify: app/lib/inside/routes/authenticated/rooms/page.dart; Create: app/lib/inside/routes/authenticated/rooms/widgets/rooms_card_owner_editor.dart (class Rooms_Card_OwnerEditor).
  • Test: extend the Rooms page widget/flow tests.

Interfaces — Consumes: Place.ownerIds, the places repository's update path, and the existing member-chip multi-select grid used by the chore per-room override. Produces: each room row gains an owner editor writing place.ownerIds.

  • Step 1: Failing widget test — tapping a room's "Owners" affordance opens the member multi-select; choosing members and confirming calls the places-update path with the new ownerIds. (Mirror the existing Rooms page test.)

  • Step 2: Run — expect FAIL.

  • Step 3: Implement Rooms_Card_OwnerEditor — an owner row per place using the existing member-chip multi-select, persisting via the Rooms bloc/repository → updatePlace(place.copyWith(ownerIds: ...)). Household-scope only; typed errors.

  • Step 4: Run — PASS.

  • Step 5: Commit feat(app): room ownership editor on the Rooms page.


Task 7: App — Today / printables use the resolver + iterate roomIds

Files:

  • Modify: the Today by-room grouping (app/lib/inside/routes/authenticated/home/** — the "By room" grouping) and RoomPrintDialog (read them first).
  • Test: extend the Today + print tests.

Interfaces — Consumes: Chore.roomIds, ChoreService.membersForRoom(…, place:). Produces: a multi-room chore lists under EACH tagged room; per-room doers come from the resolver; room-print lists iterate roomIds.

  • Step 1: Failing test — a chore tagged to two rooms appears under both in the "By room" grouping, each showing the resolved doers (owner-inherited when no override). Room-print for a room lists all chores whose roomIds contains it.

  • Step 2: Run — expect FAIL (still references placeId).

  • Step 3: Implement — swap placeId reads for roomIds membership in the grouping + print filters; compute per-room doers via membersForRoom(chore, roomId, place: place).

  • Step 4: Run — PASS.

  • Step 5: Commit feat(app): Today by-room + room-print use roomIds + membersForRoom resolver.


Task 8: Migration (file-only) + gadfly flow tests + whole-suite green

Files:

  • Create: infra/supabase/migrations/20260726000100_rooms_ownership_multiroom.sql
  • Create: app/test/flows/rooms_ownership_test.dart
  • Test: full SDK + app suites.

Migration (FILE-ONLY; prod apply DEPLOY-GATED — do NOT apply):

-- 20260726000100_rooms_ownership_multiroom.sql — room ownership + chore multi-room.
-- Additive + backfill; RLS unchanged (household-scoped, same as places/chores).

-- 1. Room ownership: multi-owner array on places (mirrors chore.assigned_member_ids).
alter table public.places
add column if not exists owner_ids uuid[] not null default '{}';

-- 2. Chore multi-room: room_ids replaces the single place_id as the room SET.
alter table public.chores
add column if not exists room_ids uuid[] not null default '{}';

-- 3. Backfill: every chore with a place_id becomes room_ids := array[place_id].
update public.chores
set room_ids = array[place_id]
where place_id is not null and (room_ids is null or room_ids = '{}');

-- place_id is retained (nullable) for one release as a safety net; a later
-- migration drops it once room_ids is proven in prod. Room-print lists + the
-- resolver read room_ids exclusively from here on.
  • Step 1: Write the migration file (above). Do NOT apply (owner-gated).

  • Step 2: Write the ONE gadfly-canonical flow testrooms_ownership_test.dart: one shared baseDescriptions (EPIC "Rooms ownership & multi-room" + STORY "A parent owns rooms and tags chores to them"), a flowTest('success', …) walking sequential tester.screenshot(...) steps — set-room-ownership → tag-a-chore-to-multiple-rooms → room-owner-inherits (a chore with no override shows the owner as doer) → per-room-override-wins (adding an override replaces the owner) — each step asserting expectations + expectedEvents. Use bounded pump(Duration(...)) where any looping animation mounts. Follow app/test/flows/sign_in_test.dart (canonical) + the harness MocksContainer.

  • Step 3: Run FULL suites — expect green, baselines not dropped

Run: cd packages/client_sdk && fvm flutter test → all pass. Run: cd app && fvm flutter test → all pass (chore-editor / Rooms / Today / print tests updated in Tasks 5-7; the new flow test green). Run: cd app && fvm flutter analyze lib → clean.

  • Step 4: If any legacy test still references Chore.placeId or the old single-select RoomPicker, repoint it to roomIds / DsMultiRoomPicker (do not delete coverage — migrate it).

  • Step 5: Commit feat: rooms ownership + multi-room migration + flow tests.


Self-Review

Spec coverage: Place.ownerIds (T1,T2) · Chore.roomIds replacing placeId + placeId→roomIds migration (T3,T8) · 3-tier membersForRoom override→owner→assignees (T3) · reusable multi-select picker (T4) · chore editor multi-room + per-room override + resolved doers (T5) · Rooms/Household owner editor (T6) · Today/printables via resolver iterating roomIds (T7) · adapters one-compile-unit + round-trip (T2,T3) · file-only migration + backfill (T8) · gadfly flow test with the four stories (T8). All spec sections mapped.

Placeholder scan: each task carries concrete file paths, code, and commands. Where a step says "read it first / mirror the existing pattern" it names the exact reference file — the SDD dispatch hands the implementer those files; the load-bearing SDK model + resolution + DS picker + migration carry full code.

Type consistency: Place.ownerIds: List<String>, Chore.roomIds: List<String>, membersForRoom(Chore, String roomId, {required Place place}), showDsMultiRoomPicker(...) → Set<String>?, DsRoomOption(id,name,floor?) are used identically across tasks. DB columns owner_ids/room_ids (uuid[]); model JSON keys owner_ids/room_ids; Drift list-converter reused from assigned_member_ids.

Known deviation flagged for the reviewer: the new membersForRoom tier 2 is place.ownerIds, REPLACING the current member.homePlaceId-based tier 2 (the spec's explicit intent). Confirm no surviving caller depends on the old home-room routing, and that HouseholdMember.homePlaceId (still the member's home-room attribute) is untouched.