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 reads — kind (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: bool— constrained toisParentalmembers; multiple owners allowed (≥ 1).MemberAccess { memberId, expiresAt? }— the untracked, time-boxed helper (babysitter): a helper-role member with no wallet/goals whose access theAuthorizerdenies onceexpiresAtpasses (NFR-AUTHZ-10).ConsentState { none, pending, granted, revoked }— onMemberas a projection of the latest consent record.ConsentRecord { id, householdId, memberRef, state, method (emailPlus|card), tosVersion, privacyVersion, verifiedAt, deadlineAt?, createdAt, actorAccountId }— append-only;Member.consentStateis the latest record's state (mirrors the ledger→wallet projection).memberRefis 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 bytermsAcceptanceRequired()(NFR-CONSENT-1, FR-ONBOARD-2).Capabilityenum +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 forkind='child'rows onceconsent_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 DEFINERhelpers mirroringparental_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).
| Capability | Owner | Admin | Helper | Member (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, ②).
helperis approve-only and completed-jobs-only — it holdsapproveCompletion+viewHouseholdAll, neverapproveSpendRequest/approveGoalRequest/ any config (NFR-AUTHZ-3). Achildsees 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(...).
3 · Consent substrate (COPPA + adult ToS)
- Child-PII write gate (dual): a write of a child member's profile/wallet/goal
data is rejected unless
consent_state='granted'with a validconsent_refFK →ConsentRequired. Enforced inConsentServiceand schema (NFR-COPPA-2, NFR-SEC-6). Offline:ConsentServicereads the local Driftconsent_statebefore 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/debitbackstop, move/fund/withdraw, redemption request, chore-submit, approval earner) and a liveassert_child_financial_consent()trigger onledger_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 noconsent_refand 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 aconsentsrow). Their bounties route to the household envelope (②, D-MONEY-3). - Adult ToS (
TermsAcceptance):termsAcceptanceRequired()blocks the session until the account has accepted the currenttosVersion/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:
checkConsentCurrencycompares the stored vs. published version at session start →granted → pendingon a material change; adeadlineAtdrives 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 withmemberRefreduced 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 anapproveCompletionholder (admin/helper) →resolvedBy = parent_manual. - Auto (
ApprovalPolicy.auto, already built): a trusted chore carries an advance parental authorization (autoApprovedByMemberId); resolves immediately viaautoApproveCompletion, stampedresolvedBy = auto_policy, still audited. "A parent chose; the system executes a choice already made." (migration20260612000003_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):
| Event | When |
|---|---|
authz_decision | a require(...) allows/denies (capability, outcome) |
authz_escalation_blocked | a member probed above their capabilities |
consent_state_changed | none/pending/granted/revoked transition |
child_pii_write_rejected | the consent gate blocked a write |
supervised_action_committed | a 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 split —
approveCompletion(admin+helper) vsapproveSpendRequest/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_refFK 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 rawisParental; 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 ②
- Models +
Authorizer(status/expiry/kind-aware, unit-tested). 2. Schema (columns + FK +consents/terms_acceptances+ helpers + triggers + RLS). - Service guards +
HouseholdServiceowner/role/transfer + zero-last + bootstrap. 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.