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:
- Actor — who 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=coParent → isParental==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
- Every mutation resolves its acting member from
CurrentMemberRepository(the authenticated account's member). Never anisParental.firstOrNull/_firstParentId()roster heuristic, and never theviewingAslens. - The
isParental.firstOrNullheuristic 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 carriesactingMemberIdwhere a page could smuggle a lens or heuristic value in (member-profile goal-approve). - 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
stepsPerMemberkey, but the actor stays the authenticated member and the parental-on-behalf edit is gated on itskind.isParental(thechores_writeRLS mirror). Two household-wide surfaces (print menu, "Bounties to earn") read the un-tailored master set, not the lens-narrowed view. - 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. - The invariant is behaviorally locked.
authz_invariant_test.dartcarries an invited-co-parent-sorts-first fixture (the exact production roster shape) that pins every approval/goal actor to the authenticated admin andverifyNevers the invited co-parent; a static guard forbids the mutation-authoring blocs from importingSelectedMemberRepository.
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)
| Slice | Commit | What |
|---|---|---|
| A | 24e3d39 | TodayChoresBloc completion approve/reject + MembersBloc chore-request/family-goal approve → authenticated member; _firstParentId() deleted; invariant fixture added. |
| A′ | c0ac82d | MemberProfileBloc 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. |
| B | 4fa4f72 | Print menu + "Bounties to earn" re-sourced to the household-wide master set (masterExpectations/masterBounties/masterParticipants), unaffected by the lens. |
| C | a08fa58 | "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-editorroute is unguarded andChoreEditorBloc.updateChorehas no app-side capability check (relies onchores_writeRLS) — 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.