Skip to main content

Authorization, personas & consent

:::note Implemented for MVP-1 — built & merged (2026-06-26) Sub-project ① (Personas & Authorization) is built and merged to main — the access + COPPA-consent substrate. Shipped: the status-aware capability matrix + pure Authorizer (service-enforced); the live owner/consent_state/consent_ref columns + consents table + append-only RLS + the zero-last / child-consent / consent-immutable / consents→member sync triggers (all on bgedvvmihygwxhjxlvfu); ConsentService (online + offline child-PII gate); supervised-action on the approval path; the 5 authz observability events; and the app UI — the /governance role/owner management page, the consent lifecycle, the adult TermsSessionGate, and the capability AdminGuard. captureConsent/revokeConsent shipped owner-only (narrower than the §2 matrix row, which has been updated). Realizes Personas / authz + decisions D-PERSONA-1…4, D-ACCOUNT-1/2, D-COPPA-1, D-CONSENT-1…5.

COPPA HARD gates — now built & live (2026-07-09). Both HARD gates that used to block real minor data are shipped and deployed on bgedvvmihygwxhjxlvfu. HARD-1 (child-financial-PII consent gate) is a dual gate: SDK choke-point guards on every financial verb (the LedgerService.credit/debit backstop, plus move/fund/withdraw, redemption request, chore-submit, and the approval earner gates) and a live assert_child_financial_consent() trigger on the five financial tables (ledger_entries/approvals/spend_requests/redemptions/token_batches) — fail-closed, so a non-consented child produces no financial rows and denials are typed ConsentRequired (G-5-promptable). HARD-2 (child consent re-capture on a policy-version bump) adds the seeded policy_versions table + current_policy_version(), member-mirror version columns, a stale-aware gate (immediate block when the granted version ≠ current), and the re-consent UX (reason-aware prompt, governance "Needs re-consent" chip, one-time owner nudge).

Still deferred (tracked): the supervised-action UX (child-on-shared-device → MVP-2 with kid-mode); role-distinction RLS on economic tables (service-enforced today, RLS follow-on); and consent lifecycle (retention / shadow ToS). Authentication covers who you are; this page covers what you may do and whether a child's data may be collected at all. :::

Where it sits

Authentication (auth) establishes the principal — the authenticated adult account. Authorization decides, for every mutation, whether that principal (acting as a household member) holds the capability — enforced in the SDK service and mirrored in Postgres RLS (the dual gate). Consent decides whether a child member's data may be persisted at all.

:::note "Viewing as" is not authorization (G-2, built) The active-member switcher (G-2, feat/mvp1-personas-authz) lets a member browse the app as another household identity — a presentation lens (SelectedMemberRepository), not a second principal. It now lives on the Profile page (the Google-account-style switcher list; it replaced the old top-header account-switcher chip). Selecting a member re-scopes what the UI displays — the top-bar/nav identity avatar, catalogs, subsections — but it is never read as actingMemberId, never fed to Authorizer.capabilitiesFor(...), and never consulted by a route guard; CurrentMemberRepository.current() stays the sole actor everywhere on this page. Proxy read-only rule: when the active member ≠ the authenticated self, member-owned surfaces (the companion sheet and My Cosmos — buy/equip/rename) are read-only, because the SDK's self-only companion mutation forbids the write; the UI hides/disables those affordances while proxied. Proxying never elevates authorization. An invariant-lock suite (app/test/unit/authz_invariant_test.dart) pins this behaviorally for the mutation-authoring blocs (MemberProfileBloc, RoleOwnerBloc, TodayChoresBloc) — including an invited-co-parent-sorts-first fixture that locks every approval actor to the authenticated admin (see the actor-resolution invariant in §2) — and statically guards the authz sites against importing the selection repository. See the account-switcher ADR.

The lens drives visibility and, for a self-scoped write, the target — never the actor or capabilities. "Customize my steps" surfaces on the viewed member's row and writes that member's stepsPerMember key, yet the write still resolves its actor from CurrentMemberRepository and gates the parental-on-behalf edit on the authenticated member's kind.isParental (the chores_write RLS mirror). Two household-wide surfaces deliberately ignore the lens and read the un-tailored master set: the per-room / per-member print menu and the "Bounties to earn" pool both render the full household roster and catalog while browsing-as a child, not the lens-narrowed view. :::

1 · Data model

Member attributes the Authorizer readskind (parent/coParent/child, with isParental), roles (admin/helper/member), owner: bool, status (MemberStatus { shadow, invited, active }already built), and consentState. Capability resolution is status-aware: only an active member holds operational capabilities; shadow/invited hold none until activated (closes the escalation vector in NFR-AUTHZ-1).

SDK (pure Dart):

  • Member.owner: boolconstrained to isParental members; multiple owners allowed (≥ 1).
  • MemberAccess { memberId, expiresAt? } — the untracked, time-boxed helper (babysitter): a helper-role member with no wallet/goals whose access the Authorizer denies once expiresAt passes (NFR-AUTHZ-10).
  • ConsentState { none, pending, granted, revoked } — on Member as a projection of the latest consent record.
  • ConsentRecord { id, householdId, memberRef, state, method (emailPlus|card), tosVersion, privacyVersion, verifiedAt, deadlineAt?, createdAt, actorAccountId }append-only; Member.consentState is the latest record's state (mirrors the ledger→wallet projection). memberRef is an opaque ref so the record survives child-PII erasure (D-CONSENT-5).
  • TermsAcceptance { accountId, tosVersion, privacyVersion, acceptedAt } — the adult ToS/Privacy acceptance (distinct from child VPC); gated at session start by termsAcceptanceRequired() (NFR-CONSENT-1, FR-ONBOARD-2).
  • Capability enum + Authorizer (pure, no I/O): capabilitiesFor(Member) → Set<Capability> (applies status + expiry + kind gates), can(...), require(...)AuthorizationFailure.

Already built (leveraged): MemberStatus; Chore.approvalPolicy (ApprovalPolicy { manual, auto }), Chore.autoApprovedByMemberId, Approval.resolvedBy { parent_manual, auto_policy } (see §4).

Schema (migrations):

  • household_members: + owner boolean not null default false (trigger: owner ⇒ parental); + consent_state text not null default 'none'; + consent_ref uuid references consents(id)NOT NULL for kind='child' rows once consent_state='granted' (the FK is a stronger backstop than a text trigger; NFR-SEC-6).
  • New consents (append-only) + terms_acceptances (adult) tables, household-/account-scoped.
  • New SECURITY DEFINER helpers mirroring parental_household_ids(): admin_household_ids(), helper_household_ids(), owner_household_ids().
  • Guard triggers: zero-last-owner, zero-last-admin, the child-PII write gate (§3), and status/expiry denial.

2 · Capability model (the dual gate)

A member's capabilities are the union granted by kind, each role, and the owner flag — then filtered by the status/expiry gate (non-active or expired ⇒ empty). coParent = parent (both isParental); admin ⇒ isParental (D-PERSONA-1); coParent = full admin (D-PERSONA-2). The parental-kind gate is intrinsic: captureConsent, revokeConsent, superviseAction, approveSpendRequest, approveGoalRequest require isParental in the capability mapping itself, so the gate survives any future admin/isParental decoupling (NFR-AUTHZ-2).

CapabilityOwnerAdminHelperMember (self)Child (supv)Layer
manageBilling, deleteHousehold
manageOwners (grant/revoke/transfer)
removeMember
manageRoles
manageHousehold (name, emoji, settings/flags, rooms)
manageCatalog (chores, rewards, activities, gates, auto-approve)
manageRoutines (steps, schedule, completion bonus)
manageBudget (default-allocation %, bucket template, permanent flag)②*
inviteMember, manageMemberProfile, manageNotificationPrefs (others)
printList (a member's / room's list)
captureConsent / revokeConsent / eraseChildData (owner-only — VPC is the account owner's legal act)
approveCompletion (manual chores)
approveSpendRequest / approveGoalRequest (parental)
superviseAction (act for a child, parental)
viewHouseholdAll (all members' wallets/goals)
createFamilyGoal / completeGoal (admin/system)
editOwnProfile (display name/email, self-scoped)✓ (adult)
editOwnPreferences (notification / accessibility / appearance — never role-gated)
submitChore, viewOwnWallet, viewSharedFamilyGoals
createOwnGoal / editOwnGoal / archiveGoal / requestGoal✓†
redeemReward (direct debit), moveOwnFunds, manageOwnEnvelopes✓†
adjustOwnRunEstimate (own run only)

*manageBudget capability is defined here; the budget editor it gates is ②. †Child self-economy/goal capabilities are gated by consent — only a consented child (consentState = granted) holds them; a supervised-only child (none) does not (admin creates on their behalf; D-PROFILE-3). The supervised earn instead routes to the household envelope (§3, ②).

helper is approve-only and completed-jobs-only — it holds approveCompletion + viewHouseholdAll, never approveSpendRequest / approveGoalRequest / any config (NFR-AUTHZ-3). A child sees self + shared Family Goals only (D-PERSONA-3).

Service half: every mutation opens with authorizer.require(cap, target). Schema half: each surface gets an RLS policy keyed to the matching *_household_ids() helper. A capability is "done" only when both halves enforce it — and route guards + badges call authorizer.can(...), never raw isParental (reconciles FR-SETTINGS-2/3 so a non-parental helper's queue badge still shows).

Actor-resolution invariant (reinforced 2026-07-10). Every mutation resolves its acting member from CurrentMemberRepository (the authenticated account's member) — never an isParental.firstOrNull roster scan and never the viewingAs lens. A live regression made this load-bearing: three approval sites (TodayChoresBloc completion approve/reject, MembersBloc chore-request + family-goal approve, MemberProfileBloc goal-approve) had picked the first parental member, which on the production roster is an invited co-parent (isParental, status=invited → the status gate grants nothing), so every parent approval failed with AuthorizationFailure. The fix routes all three through the authenticated-member resolver (and a null actor now surfaces a user-facing error rather than a silent no-op); the isParental.firstOrNull value is kept only for display gating (show/hide the approve affordance). The invited-co-parent-sorts-first fixture in authz_invariant_test.dart locks each actor to the authenticated admin.

The parental-write dual gate is generalized. The SDK twin of the RLS parental_household_ids() rule — EconomyService._requireParentalWriter — resolves the authenticated member (never the lens/heuristic) and throws a typed AuthorizationFailure before the round-trip when the actor is not isParental, matching the parental-KIND RLS. It now covers activities, rewards, houses, places, budget categories, spend gates, and the goal lifecycle — closing the class where an admin-role but non-parental member (e.g. an otherAdult granted admin) cleared the app's admin-role guard, then hit a raw Postgres 42501 at the wire. (Chore-catalog writes live in ChoreService, which lacks the resolver — that twin is dashed / deferred.)

Bootstrap: createHousehold grants the creator owner + admin atomically with the household row — the one path that precedes a member record, so it is an explicit, audited exception to require(...).

  • Child-PII write gate (dual): a write of a child member's profile/wallet/goal data is rejected unless consent_state='granted' with a valid consent_ref FK → ConsentRequired. Enforced in ConsentService and schema (NFR-COPPA-2, NFR-SEC-6). Offline: ConsentService reads the local Drift consent_state before any local child-PII write; a revoke while offline tombstones pending child writes and is reconciled on sync (NFR-AUTHZ-6, NFR-OFFLINE-6).
  • Financial-PII HARD gates (built & live, 2026-07-09): beyond the profile-data gate above, HARD-1 enforces the child-financial-PII gate at every SDK financial choke point (the LedgerService.credit/debit backstop, move/fund/withdraw, redemption request, chore-submit, approval earner) and a live assert_child_financial_consent() trigger on ledger_entries/approvals/spend_requests/redemptions/token_batches — a non-consented child produces no financial rows (fail-closed). HARD-2 extends the same gate to reject a granted-but-stale consent when the accepted policy version ≠ current_policy_version() (immediate block), driving the re-consent prompt.
  • Supervised-only ≠ a consent record. A supervised child keeps consent_state='none' with no consent_ref and no child PII; the "VPC-before-addMember" rule applies to the consented-account path only (reconciles the onboarding note — supervised acknowledgment is a UI step, not a consents row). Their bounties route to the household envelope (②, D-MONEY-3).
  • Adult ToS (TermsAcceptance): termsAcceptanceRequired() blocks the session until the account has accepted the current tosVersion/privacyVersion (NFR-CONSENT-1, FR-ONBOARD-2). Shadow member ToS: the admin accepts on the household's behalf at creation; the shadow member accepts their own on claim/activation — supplements, never replaces (D-CONSENT-3).
  • Lifecycle: checkConsentCurrency compares the stored vs. published version at session start → granted → pending on a material change; a deadlineAt drives the re-consent grace window (the mechanism; the 14-day / 85% numbers are ⚖️ counsel-confirm, D-CONSENT-4). On erasure, the consent event is retained with memberRef reduced to an opaque token (D-CONSENT-5).

4 · Approval policy & supervised action

Two earn paths, one token-moving rule — credit always flows through ApprovalService + the household EarningsSplit:

  • Manual (ApprovalPolicy.manual, default): waits for an approveCompletion holder (admin/helper) → resolvedBy = parent_manual.
  • Auto (ApprovalPolicy.auto, already built): a trusted chore carries an advance parental authorization (autoApprovedByMemberId); resolves immediately via autoApproveCompletion, stamped resolvedBy = auto_policy, still audited. "A parent chose; the system executes a choice already made." (migration 20260612000003_auto_approve_policy.sql, SQL-twin CHECKs — dual gate holds.)

Supervised action rides the same paths under the parent's session (no child login); the record carries actingMemberId (child) ≠ principalAccountId (parent):

An auto chore skips the per-action tap even for a supervised child — the standing authorization covers it, still recorded. The earn routes by consent state. ① adds the actingMemberId extension + superviseAction gating over the already-built approval-policy path.

5 · Authz observability (internal-BI, no child PII)

The KPI (≥ 95% matrix coverage; zero child-PII leak) needs evidence, so the SDK service emits these events with opaque household-scoped IDs only, tagged internal-BI, scrubbed by the analytics pre-emit filter (NFR-AUTHZ-9, NFR-ANALYTICS-1/2/7):

EventWhen
authz_decisiona require(...) allows/denies (capability, outcome)
authz_escalation_blockeda member probed above their capabilities
consent_state_changednone/pending/granted/revoked transition
child_pii_write_rejectedthe consent gate blocked a write
supervised_action_committeda supervised earn resolved (manual/auto)

6 · App-UI surfaces, errors, testing

Surfaces (full vertical): ① role & owner management (assign role; grant/revoke owner ≥ 1; transfer); ② consent lifecycle (start VPC, none→…→revoked, revoke) + adult ToS gate; ③ supervised-action UX (manual tap / auto silent); ④ capability-aware route guards + "no permission" surface. (The auto-approve toggle, chore/room print buttons, and pref toggles already exist; ① only adds the capability gate around them.)

Errors (→ user messages, no raw PII): AuthorizationFailure (RLS denials map here), LastOwnerCannotDeleteWithMembers, LastAdminCannotDemote, OwnerMustBeParental, ConsentRequired, TermsAcceptanceRequired, MemberAccessExpired.

Testing: flow tests per surface; integration tests for guards + the consent gate (online and offline Drift); RLS-parity tests (schema denies identically); zero-last-owner/admin; helper-cannot-approve-spend/goal; status/expiry denial; supervised over manual and auto. Suite ≥ baseline.

7 · Requirements coverage (what the sweep closed)

Verified against all 20 epics. Resolved gaps/conflicts now in the design above:

  • Approval splitapproveCompletion (admin+helper) vs approveSpendRequest/ approveGoalRequest (admin/parental only). NFR-AUTHZ-3, FR-CATALOG-4.
  • MemberStatus + expiry gating + the untracked time-boxed helper. NFR-AUTHZ-1, NFR-AUTHZ-10.
  • Capabilities added: removeMember, manageRoutines, createFamilyGoal, completeGoal, redeemReward, moveOwnFunds, manageOwnEnvelopes, editOwnProfile, editOwnPreferences, adjustOwnRunEstimate, printList, manageNotificationPrefs. FR-MONEY-1/7/9, FR-HOUSEHOLD-4/5, FR-PROFILE-7…11, FR-CATALOG-4, FR-ROUTINE-1/5, D-TIMER-3, FR-PRINT-2/3, FR-SETTINGS-4/5, FR-NOTIFY-2, FR-ACCOUNT-7.
  • Adult ToS record + session gate; consent lifecycle (currency check, SLA mechanism, post-erasure retention, shadow ordering). NFR-CONSENT-1…5, NFR-COPPA-8, FR-ONBOARD-2/3.
  • consent_ref FK backstop; offline consent gate + tombstone. NFR-SEC-6, NFR-AUTHZ-6, NFR-OFFLINE-6.
  • Authz events + pre-emit scrub. NFR-AUTHZ-9, NFR-ANALYTICS-1/2/7.
  • Guards use can(...) not raw isParental; bootstrap owner grant. FR-SETTINGS-2/3, FR-ONBOARD-1.

Still deferred by decision (not ① gaps): the 14-day/85% re-consent numbers and VPC method (⚖️ counsel); non-parental admin & co-parent delegated scopes (self-governed mode); the money behaviors behind the ② capabilities.

Build phases & boundary with ②

  1. Models + Authorizer (status/expiry/kind-aware, unit-tested). 2. Schema (columns + FK + consents/terms_acceptances + helpers + triggers + RLS).
  2. Service guards + HouseholdService owner/role/transfer + zero-last + bootstrap.
  3. ConsentService (online + offline gate, ToS, lifecycle). 5. Supervised-action (reuses the built manual/auto path). 6. Authz events. 7. App-UI + capability route guards. 8. Integration / RLS-parity + flip this page proposed → built.

② (Money Envelopes) consumes the capabilities tagged ② above and the consentState signal — envelope generalization, transfers, Unallocated, goal balances, EarningsSplit wiring, and the supervised-attribution household envelope. See the Money epic and decisions log.