Skip to main content

Actor-resolution invariant — the mutation actor is always the authenticated member

Date: 2026-07-11 Status: Accepted — enforced in code + locked by authz_invariant_test.dart. Related: account-switcher ADR, role-governance ADR, authored Authorization page.

Context

Rewhaven has two member references that are easy to conflate:

  • Actorwho is performing this mutation. Must always be the authenticated account's member, resolved from CurrentMemberRepository.current().
  • Lens (SelectedMemberRepository, the "viewing-as" account switcher) — a presentation-only identity for browsing the app as another household member.

A live production incident proved the conflation is not hypothetical. Three approval sites resolved their actor from members.where((m) => m.kind.isParental).firstOrNull (a roster scan) instead of the authenticated member. On the real household the first parental member is an invited co-parent (kind=coParentisParental==true, status=invited), whom the Authorizer status gate grants nothing. Result: the signed-in admin/owner (Dad) could not approve anything — every approval threw AuthorizationFailure. The dual gate was correct; it was simply being handed the wrong subject.

Decision

  1. Every mutation resolves its acting member from CurrentMemberRepository (the authenticated account's member). Never an isParental.firstOrNull / _firstParentId() roster heuristic, and never the viewingAs lens.
  2. The isParental.firstOrNull heuristic is banned as an actor. It may be retained only for display gating (show/hide an approve affordance when some parental member exists). A page must not be able to supply an actor id to a bloc — the authored event no longer carries actingMemberId where a page could smuggle a lens or heuristic value in (member-profile goal-approve).
  3. The lens feeds visibility and, for self-scoped writes, the write target — never the actor or capabilities. "Customize my steps" surfaces on the viewed member's row and writes that member's stepsPerMember key, but the actor stays the authenticated member and the parental-on-behalf edit is gated on its kind.isParental (the chores_write RLS mirror). Two household-wide surfaces (print menu, "Bounties to earn") read the un-tailored master set, not the lens-narrowed view.
  4. A null/unresolved actor surfaces a user-facing error, never a silent no-op (the codebase's E5 posture). The null actor is never smuggled into the can(...) call.
  5. The invariant is behaviorally locked. authz_invariant_test.dart carries an invited-co-parent-sorts-first fixture (the exact production roster shape) that pins every approval/goal actor to the authenticated admin and verifyNevers the invited co-parent; a static guard forbids the mutation-authoring blocs from importing SelectedMemberRepository.

Why banned (the anti-pattern)

isParental.firstOrNull assumes "the first parental member == the acting parent." That is false whenever the roster contains an invited/shadow parental placeholder, a second co-parent, or any ordering the DB happens to return. It couples authorization to roster order — a silent correctness bomb. The authenticated member is the only defensible actor: it is who is actually holding the phone.

Implementation (shipped)

SliceCommitWhat
A24e3d39TodayChoresBloc completion approve/reject + MembersBloc chore-request/family-goal approve → authenticated member; _firstParentId() deleted; invariant fixture added.
A′c0ac82dMemberProfileBloc goal-approve (third site) → authenticated member; removed actingMemberId from MemberProfileGoalApproved so the page can't supply one; null-actor now surfaces an error instead of a silent no-op.
B4fa4f72Print menu + "Bounties to earn" re-sourced to the household-wide master set (masterExpectations/masterBounties/masterParticipants), unaffected by the lens.
Ca08fa58"Customize my steps" writes the viewed member's steps (lens = target) while the actor stays authenticated and parental-on-behalf is isParental-gated.

Dual gate (service Authorizer + Postgres RLS) unchanged — the fixes only correct the subject fed to it.

Out of scope (recorded, not built)

  • The /chore-editor route is unguarded and ChoreEditorBloc.updateChore has no app-side capability check (relies on chores_write RLS) — aligning the editor's own gate is a separate follow-up (cf. the parental-write gate ADR).
  • The static-guard site count is not a public contract; new authz-authoring blocs must be added to the invariant suite as they land.