Skip to main content

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_id nullable NON-unique FK. Never add a unique constraint on auth_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.dart must stay green + falsifiable). The switcher gate change is presentation-only by construction.
  • Suite baselines: app 406 / SDK 864 — never drop; no *_test.dart deletions.
  • FVM (fvm flutter test per package), graphify-first, explicit git 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)

WhatWhere
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)

WhatWhere
canSwitchAccounts bool default false: model + json + copyWith/propshousehold_member.dart (mirror watchOnly shape)
All adapters: in-memory, cached, Drift (schema bump, additive column), cloudpackages/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 viewHouseholdAllapp/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 testsapp/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)

WhatWhere
inviteMember({actingMemberId, memberId, email, note?, validFor}): adult-kind + unlinked + not-already-invited guards; stamps token/expiry/email on the EXISTING row; status stays activefacade 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 statusservice 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

WhatWhere
Role editor: admin/helper controls DISABLED + hint "Invite ⟨name⟩ to a user account to enable roles" when target unlinkedgovernance role editor (M1 surface)
Helper toggle "Allow account switching" — visible only on linked helpers; persists via existing member-update pathsame 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 householdSetupPage + auth flow (P1 memo already clears on no-member)
A11y floor on every new affordance: Semantics + ≥48px + AAthroughout
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)

WhatWhere
PRE: audit live members rows for admin/helper with auth_user_id is nullSupabase 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 nullmigration 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.

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) — inviteMember child guard is the seam.
  • Email/deep-link invite delivery (code entry is the MVP; deep link prefills later).
  • Multi-household account handling (newest-first memberForAccount stands).