Role governance (RG) — implementer plan
Binding spec:
docs/superpowers/specs/2026-07-04-role-governance-account-linked-roles-design.md
(owner decisions §"Owner decisions" are non-negotiable). Scoped at HEAD ≥ 3c56e09.
Build loop: TDD vertical slices (failing test → minimal impl → green → commit),
review per slice, push feat+main, deploy at the user-visible landings.
Sequencing vs H-phase: RG is independent of H-1..H-4. It SHOULD land before or with H-5 (H-5 consumes the switcher; RG changes the switcher gate). One implementer at a time on the branch — coordinate with the H-phase session; do not interleave mutating agents.
Cross-cutting constraints
- Identity model is binding vocabulary (spec §"Identity model"): User = global auth
account (
auth.users); Member = household-scoped profile row; Link =auth_user_idnullable NON-unique FK. Never add a unique constraint onauth_user_id— one user may link to member rows in multiple households (RLS helpers are already plural). The co-parent placeholder IS the "create member during linking" implementation — do not build a separate invites table. - One data path: Bloc → Repository → Client facade → Service → Adapter.
- Dual gate: service authz AND Postgres RLS/trigger for privileged mutations.
- viewingAs stays presentation-only (
app/test/unit/authz_invariant_test.dartmust stay green + falsifiable). The switcher gate change is presentation-only by construction. - Suite baselines: app 406 / SDK 864 — never drop; no
*_test.dartdeletions. - FVM (
fvm flutter testper package), graphify-first, explicitgit add <paths>. - Migrations here are additive (new column, new trigger) — safe to apply ahead of deploy; NO renames.
- Supabase project
bgedvvmihygwxhjxlvfu, anon key only.
Slices
RG-S1 — SDK linkage guard + member-default invites (S/M, no migration)
| What | Where |
|---|---|
Linkage guard: setRole throws ValidationException when target authUserId == null and roles ⊇ {admin}∨{helper} | packages/client_sdk/lib/src/services/household_service.dart — BOTH entry points, beside the existing _requireRoleKind guards (d2b3b7a precedent) |
Invite default: inviteCoParent placeholder roles {admin} → {member} | same service |
Tests: guard matrix (unlinked admin ✗ / unlinked helper ✗ / unlinked member-only ✓ / linked admin ✓ / bootstrap unaffected); invite creates {member} | packages/client_sdk/test/services/household_service_authz_test.dart + invite tests |
Gate: SDK green. Note: this slice makes the app's invite copy stale — fix in RG-S4; acceptable transiently because the invite UI still works (roles just land as member).
RG-S2 — canSwitchAccounts field + switcher gate (M, additive migration)
| What | Where |
|---|---|
canSwitchAccounts bool default false: model + json + copyWith/props | household_member.dart (mirror watchOnly shape) |
| All adapters: in-memory, cached, Drift (schema bump, additive column), cloud | packages/client_sdk/lib/src/adapters/** + fake_port.dart + seed_factories.dart |
Migration member_can_switch_accounts (additive column) | infra/supabase/migrations/ — apply live after review (additive-safe) |
Switcher predicate: admin ∥ owner ∥ (helper && canSwitchAccounts); do NOT touch viewHouseholdAll | app/lib/outside/repositories/selected_member/selected_member_repository.dart:163 _canOfferHouseholdIdentitySwitcher |
| Tests: switcher matrix (admin ✓ / owner ✓ / helper-unflagged self-only / helper-flagged ✓ / member self-only) in repo + cubit tests | app/test/unit/selected_member_repository_test.dart, account_switcher_cubit_test.dart |
Gate: SDK + app green. ⚠ Behavior change: helpers lose automatic switcher access — call out in the review + ledger.
RG-S3 — inviteMember verb for existing adult members (M, no migration)
| What | Where |
|---|---|
inviteMember({actingMemberId, memberId, email, note?, validFor}): adult-kind + unlinked + not-already-invited guards; stamps token/expiry/email on the EXISTING row; status stays active | facade client.dart + client_impl.dart + household_service.dart + StoragePort impls (reuse the G-7 invite-field plumbing) |
Verify + pin: acceptInvite on an active-row invite links authUserId, clears token, does NOT flip status | service test |
| Tests: guard matrix (child ✗ / linked ✗ / already-invited ✗ / adult-unlinked ✓); accept e2e (invite existing → accept → linked + active); promote-after-accept e2e (RG-S1 guard now passes) | SDK tests |
Gate: SDK green.
RG-S4 — app UI (M/L) — the user-visible landing
| What | Where |
|---|---|
| Role editor: admin/helper controls DISABLED + hint "Invite ⟨name⟩ to a user account to enable roles" when target unlinked | governance role editor (M1 surface) |
| Helper toggle "Allow account switching" — visible only on linked helpers; persists via existing member-update path | same editor |
| Invite copy: "full admin once they accept" → "joins as a member — you can promote them after they accept" | G-7 invite segment |
| "Invite to account" affordance on adult unlinked member profiles → email + note → share-code sheet (copy affordance; resend regenerates) | member profile / members management |
SetupPage "Have an invite code?" → code entry → acceptInvite(token, authUserId) → bootstrap re-resolves into household | SetupPage + auth flow (P1 memo already clears on no-member) |
| A11y floor on every new affordance: Semantics + ≥48px + AA | throughout |
| Flow tests: editor disabled-state + hint; toggle round-trip; invite-existing share-code; setup code-entry joins household (wizard not shown) | app/test/flows/ |
Gate: app green → review → push → DEPLOY → verify 200.
RG-S5 — Postgres guard trigger + data fix + docs (S/M, additive migration)
| What | Where |
|---|---|
PRE: audit live members rows for admin/helper with auth_user_id is null | Supabase MCP read |
Data fix: demote the pending spouse placeholder {admin} → {member} (owner-approved in spec) | one UPDATE in the migration |
Guard trigger: reject admin/helper role arrays when auth_user_id is null | migration member_role_requires_account; RLS-parity-style test |
ADR docs/decisions/2026-07-04-role-governance.md + architecture page truthing (authorization.md note) | docs |
Gate: SDK/app green (no behavior change expected — service guard already enforces) → push. No deploy needed (DB + docs only) unless batched with RG-S4.
Recommended order
RG-S1 → RG-S2 → RG-S3 → RG-S4 (deploy) → RG-S5. S1..S3 are SDK-only and individually committable; S4 is the single user-visible landing; S5 hardens the DB after the app no longer produces admin-with-null-auth rows.
Deferred (recorded, do not build)
- Child/kid accounts (COPPA HARD gates first) —
inviteMemberchild guard is the seam. - Email/deep-link invite delivery (code entry is the MVP; deep link prefills later).
- Multi-household account handling (newest-first
memberForAccountstands).