Skip to main content

MVP-1 ① Personas & Authorization — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the status-aware owner/role/kind capability matrix (enforced in SDK service AND Postgres RLS — the dual gate), the append-only consent substrate (adult ToS + child VPC with the child-PII write gate, online + offline), the supervised-action path over the built approval policy, and the authz observability events.

Architecture: A pure Authorizer resolves capabilitiesFor(member) (union over kind/roles/owner, then filtered by status + expiry + parental-kind gate); every SDK service mutation opens with authorizer.require(cap, target), and each capability is mirrored by an RLS SECURITY DEFINER helper + policy. Consent gates child-PII persistence in service and schema, online and offline. Supervised action reuses the existing single token-moving path.

Tech Stack: Dart 3.9 / Flutter 3.44 (FVM: fvm flutter / fvm dart), pub workspace; packages/client_sdk (pure Dart, package:supabase core), Drift (local), Supabase Postgres + RLS (cloud, project bgedvvmihygwxhjxlvfu); app blocs + auto_route.

Design source of truth: app/test-gallery/authored/developer/architecture/authorization.md (capability matrix §2, consent §3, supervised §4, events §5, coverage §7). Spec: docs/superpowers/specs/2026-06-25-mvp1-personas-authz-foundation-design.md.

Global Constraints

  • client_sdk stays pure Dartpackage:supabase core only, no flutter_* dep.
  • No service-role key — anon/publishable key only (sb_publishable_…), RLS-enforced; reuse the SP2 SupabaseClient; privileged ops via Edge Functions only.
  • Dual gate — every capability + the consent gate enforced in service AND RLS, in the same task, with an RLS-parity test.
  • Household-scoped — zero cross-household access; helpers keyed to auth.uid().
  • No child PII in events — opaque household-scoped IDs only; tag internal-BI; pass the pre-emit scrub.
  • Append-only consents; Member.consentState is a projection; consent event survives child-PII erasure (opaque ref).
  • Offline parity — consent gate holds against the local Drift store.
  • Tests: async-throw uses await expectLater(fut, throwsA(isA<X>())); TRUE exit codes (cmd > /tmp/t.txt 2>&1; echo EXIT=$?), never pipe test runs to tail; build_runner scoped from packages/client_sdk (--build-filter) + restore clobbered .g.dart/.gr.dart siblings from HEAD; suite count ≥ baseline; verify new columns via Supabase MCP information_schema.
  • Git: no destructive ops, explicit git add (never bare -A), never delete *_test.dart.
  • graphify-first before touching code (graphify query "<q>"); graphify update . after.
  • Leverage already-built (do NOT rebuild): MemberKind/isParental, MemberRole, MemberStatus, ledger + zero-floor, ApprovalPolicy/autoApproveCompletion/resolvedBy.
  • Review routing per phase: flutter-reviewer (Dart), database-reviewer (RLS/migration), security-reviewer (consent gate/secrets/dual-gate).

Baseline first (do before Task 1)

Record the green baseline so "suite ≥ baseline" is checkable: cd packages/client_sdk && fvm dart test > /tmp/baseline_sdk.txt 2>&1; echo EXIT=$? and cd app && fvm flutter test > /tmp/baseline_app.txt 2>&1; echo EXIT=$?. Note both pass-counts in the progress ledger.

File structure

FileResponsibility
packages/client_sdk/lib/src/models/capability.dartCapability enum (the authz verbs)
packages/client_sdk/lib/src/models/consent_state.dart · consent_method.dartconsent enums
packages/client_sdk/lib/src/models/consent_record.dart · terms_acceptance.dart · member_access.dartconsent + access value types
packages/client_sdk/lib/src/services/authorizer.dartpure capability resolution + require
packages/client_sdk/lib/src/services/consent_service.dartconsent capture/gate/lifecycle
…/models/household_member.dart (modify)+ owner, + consentState, + consentRef
…/models/exceptions.dart (modify)new domain exceptions
…/services/household_service.dart (modify)owner/role mutations, guards, removeMember, bootstrap
…/services/approval_service.dart (modify)actingMemberId supervised extension
…/adapters/local/* · …/adapters/cloud/* (modify)persist the new columns/tables
infra/supabase/migrations/2026…*.sqlcolumns, tables, helpers, RLS, triggers
app/lib/** (repos, blocs, pages, guards)role/owner, consent, supervised UX, route guards

Phase 1 — Pure Authorizer (no I/O)

Files:

  • Create: packages/client_sdk/lib/src/models/capability.dart, consent_state.dart, consent_method.dart
  • Test: packages/client_sdk/test/models/capability_test.dart

Interfaces — Produces:

  • enum Capability { manageBilling, deleteHousehold, manageOwners, removeMember, manageRoles, manageHousehold, manageCatalog, manageRoutines, manageBudget, inviteMember, manageMemberProfile, manageNotificationPrefs, printList, captureConsent, revokeConsent, eraseChildData, approveCompletion, approveSpendRequest, approveGoalRequest, superviseAction, viewHouseholdAll, createFamilyGoal, completeGoal, editOwnProfile, editOwnPreferences, submitChore, viewOwnWallet, viewSharedFamilyGoals, createOwnGoal, editOwnGoal, archiveGoal, requestGoal, redeemReward, moveOwnFunds, manageOwnEnvelopes, adjustOwnRunEstimate }

  • enum ConsentState { none, pending, granted, revoked } with fromWireName/wireName (mirror the pattern in models/member_status.dart).

  • enum ConsentMethod { emailPlus, card } with wire names email_plus/card.

  • Step 1 — failing test capability_test.dart:

import 'package:client_sdk/src/models/consent_state.dart';
import 'package:test/test.dart';
void main() {
test('ConsentState round-trips wire names', () {
expect(ConsentState.granted.wireName, 'granted');
expect(ConsentState.fromWireName('revoked'), ConsentState.revoked);
});
test('fromWireName rejects unknown', () {
expect(() => ConsentState.fromWireName('bogus'), throwsArgumentError);
});
}
  • Step 2 — run, expect FAIL (fvm dart test test/models/capability_test.dart): missing file.
  • Step 3 — implement the three enums. Mirror the existing wire-name pattern in member_status.dart (read it first via graphify: graphify explain "MemberStatus"). Capability needs no wire name (never persisted).
  • Step 4 — run, expect PASS.
  • Step 5 — commit: git add packages/client_sdk/lib/src/models/capability.dart packages/client_sdk/lib/src/models/consent_state.dart packages/client_sdk/lib/src/models/consent_method.dart packages/client_sdk/test/models/capability_test.dart && git commit -m "feat(sdk): Capability + consent enums"

Task 2: MemberAccess + the Authorizer

Files:

  • Create: packages/client_sdk/lib/src/models/member_access.dart, packages/client_sdk/lib/src/services/authorizer.dart
  • Test: packages/client_sdk/test/services/authorizer_test.dart

Interfaces — Consumes: Capability (T1); existing HouseholdMember (fields kind, roles, status; isParental on kind), MemberStatus. Produces:

  • class MemberAccess { final String memberId; final DateTime? expiresAt; const MemberAccess({required this.memberId, this.expiresAt}); }
  • class Authorizer { Set<Capability> capabilitiesFor(HouseholdMember m, {MemberAccess? access, DateTime? now}); bool can(HouseholdMember m, Capability c, {MemberAccess? access, DateTime? now}); void require(HouseholdMember m, Capability c, {MemberAccess? access, DateTime? now}); }require throws AuthorizationFailure (T3).

Resolution rules (from design §2): union over kind+roles+owner; then if status != activeempty; if access?.expiresAt is before nowempty. Parental-kind gate is intrinsic: captureConsent/revokeConsent/eraseChildData/superviseAction/approveSpendRequest/approveGoalRequest require m.kind.isParental. owner flag grants {manageBilling, deleteHousehold, manageOwners}. admin grants the config set; helper grants only {approveCompletion, viewHouseholdAll}; every member self-row grants {submitChore, viewOwnWallet, viewSharedFamilyGoals, editOwnPreferences, adjustOwnRunEstimate, editOwnProfile(adult)}; consented-member self grants {createOwnGoal, editOwnGoal, archiveGoal, requestGoal, redeemReward, moveOwnFunds, manageOwnEnvelopes}.

  • Step 1 — failing tests (real matrix coverage):
// helper may approve completions but NOT spend/goal
test('helper is approve-only, completed-jobs-only', () {
final m = member(roles: {MemberRole.helper}, status: MemberStatus.active);
expect(auth.can(m, Capability.approveCompletion), isTrue);
expect(auth.can(m, Capability.approveSpendRequest), isFalse);
expect(auth.can(m, Capability.approveGoalRequest), isFalse);
expect(auth.can(m, Capability.manageCatalog), isFalse);
});
test('non-active member holds nothing', () {
final m = member(roles: {MemberRole.admin}, status: MemberStatus.invited);
expect(auth.capabilitiesFor(m), isEmpty);
});
test('expired access revokes all capabilities', () {
final m = member(roles: {MemberRole.helper}, status: MemberStatus.active);
final past = DateTime.utc(2020); final now = DateTime.utc(2026);
expect(auth.capabilitiesFor(m, access: MemberAccess(memberId: m.id, expiresAt: past), now: now), isEmpty);
});
test('owner caps require parental and owner flag', () { /* owner+parental → manageBilling true; child → false */ });
test('require throws AuthorizationFailure when denied', () {
expect(() => auth.require(member(status: MemberStatus.active), Capability.manageBilling), throwsA(isA<AuthorizationFailure>()));
});

(Provide a member({roles, kind, owner, status}) test factory at the top — mirror test/support/ factories; read graphify explain "HouseholdMember" for the exact constructor.)

  • Step 2 — run, expect FAIL.
  • Step 3 — implement MemberAccess + Authorizer per the rules above. Pure: no await, no I/O.
  • Step 4 — run, expect PASS — all matrix rows.
  • Step 5 — commit.

Task 3: domain exceptions

Files: Modify packages/client_sdk/lib/src/models/exceptions.dart; Test packages/client_sdk/test/models/authz_exceptions_test.dart. Produces: AuthorizationFailure, LastOwnerCannotDeleteWithMembers, LastAdminCannotDemote, OwnerMustBeParental, ConsentRequired, TermsAcceptanceRequired, MemberAccessExpired — each extending the existing DomainRuleException base (read graphify explain "DomainRuleException" for the base shape; mirror InsufficientBalanceException). Each carries a non-PII message.

  • Step 1 — failing test: assert each is a DomainRuleException and its message contains no member name/email (only ids/role words).
  • Step 2 — FAIL. Step 3 — implement (mirror existing exception classes). Step 4 — PASS. Step 5 — commit.

Phase 1 review: flutter-reviewer. Gate: matrix coverage complete, pure (no I/O), exceptions PII-free.


Phase 2 — Schema + RLS (the schema half of the dual gate)

Migrations are additive-only (offline-first window). Mirror the existing style in infra/supabase/migrations/20260612000003_auto_approve_policy.sql (header comment explaining the invariant + the SQL-twin note) and the RLS helper parental_household_ids() (find it: graphify query "parental_household_ids RLS household policy" or grep the migrations dir). Use a timestamp prefix after the latest existing migration. After each migration: apply via Supabase MCP apply_migration to bgedvvmihygwxhjxlvfu, then verify columns via execute_sql against information_schema.columns.

Task 4: household_members columns + owner⇒parental trigger

Files: Create infra/supabase/migrations/<ts>_authz_owner_consent_columns.sql; Test (parity) packages/client_sdk/test/cloud/authz_schema_test.dart (guarded by the live-creds flag, mirror test/cloud/supabase_chores_test.dart). SQL:

alter table public.household_members
add column owner boolean not null default false,
add column consent_state text not null default 'none'
check (consent_state in ('none','pending','granted','revoked')),
add column consent_ref uuid; -- FK added in Task 5 once consents exists

-- owner must be a parental member (parent | co_parent). SQL twin of Authorizer.
create or replace function public.assert_owner_is_parental() returns trigger
language plpgsql as $$
begin
if new.owner and new.kind not in ('parent','co_parent') then
raise exception 'OwnerMustBeParental';
end if;
return new;
end $$;
create trigger trg_owner_is_parental before insert or update on public.household_members
for each row execute function public.assert_owner_is_parental();
  • Steps: write the migration → apply_migration → verify both columns present + the check via information_schema (MCP execute_sql) → parity test asserts a non-parental owner insert is rejected → commit (git add the migration + test).

SQL (append-only consents; member_ref opaque so it survives erasure):

create table public.consents (
id uuid primary key default gen_random_uuid(),
household_id uuid not null references public.households(id),
member_ref uuid, -- nullable after erasure (tombstoned)
state text not null check (state in ('none','pending','granted','revoked')),
method text check (method in ('email_plus','card')),
tos_version text not null, privacy_version text not null,
verified_at timestamptz, deadline_at timestamptz,
created_at timestamptz not null default now(),
actor_account_id uuid not null
);
create table public.terms_acceptances (
account_id uuid not null, tos_version text not null, privacy_version text not null,
accepted_at timestamptz not null default now(),
primary key (account_id, tos_version, privacy_version)
);
create table public.member_access (
member_id uuid primary key references public.household_members(id),
expires_at timestamptz
);
alter table public.household_members
add constraint household_members_consent_ref_fkey
foreign key (consent_ref) references public.consents(id);
  • Append-only enforced via RLS (no UPDATE/DELETE policy) in Task 7. Steps: migration → apply → verify tables/FK via MCP → parity test (insert consent, link member) → commit.

Task 6: role helper functions

SQL (mirror parental_household_ids() exactly — same security definer, set search_path, auth.uid() keying):

create or replace function public.admin_household_ids() returns setof uuid
language sql security definer set search_path = public stable as $$
select household_id from public.household_members
where auth_user_id = auth.uid() and status = 'active'
and 'admin' = any(roles) and kind in ('parent','co_parent') $$;
-- helper_household_ids(): same but 'helper' = any(roles); owner_household_ids(): owner = true and kind parental.
  • Steps: write all three → apply → verify via MCP (select returns the caller's households) → commit.

Task 7: RLS policies per surface + guard triggers

  • Policies: owner-only surfaces keyed to owner_household_ids(); admin config surfaces to admin_household_ids(); approval surfaces to admin ∪ helper; spend/goal approval to admin only; consents/terms_acceptances insert-only (no update/delete → append-only).
  • Triggers (SQL twins of the service guards): zero-last-owner + zero-last-admin (raise on delete/demote of the last one in a household with other members); child-PII write gate (reject insert/update of a kind='child' member's profile/wallet/goal rows unless consent_state='granted' with a non-null consent_ref); status/expiry (deny if member not active or member_access.expires_at < now()).
  • Test (RLS-parity): with two seeded households + an anon-key client, assert cross-household read returns 0 rows and a helper cannot insert a spend approval; assert a child-PII write without consent raises. Steps: migration → apply → MCP verify policies exist (pg_policies) → parity test → commit.

Phase 2 review: database-reviewer + security-reviewer. Gate: additive-only, no service-role anywhere, helpers mirror parental_household_ids(), append-only holds, every column verified via information_schema.


Phase 3 — Service guards + HouseholdService

Task 8: HouseholdMember + adapters carry the new fields

Files: Modify household_member.dart (+ bool owner, + ConsentState consentState, + String? consentRef; update ctor, copyWith, props, JSON), the local Drift mapper, the cloud row-mapper. Test test/models_round_trip_test.dart (extend) + test/local_storage_adapter_test.dart. Interfaces — Produces: the extended HouseholdMember. Consumes: ConsentState (T1).

  • Step 1 — failing test: round-trip a member with owner: true, consentState: granted, consentRef: <uuid> through camelCase↔snake_case mappers and Drift. Use await expectLater only where async. Run codegen scoped: cd packages/client_sdk && fvm dart run build_runner build --build-filter 'lib/src/models/household_member.g.dart' --build-filter 'lib/src/adapters/local/local_database.g.dart' then restore any clobbered sibling .g.dart from HEAD.
  • Steps 2–5 as standard. Commit includes regenerated .g.dart.

Task 9: owner/role mutations + zero-last guards + removeMember

Files: Modify household_service.dart; Test test/services/household_service_authz_test.dart. Interfaces — Produces: Future<void> setRole(...), Future<void> grantOwner(memberId), Future<void> revokeOwner(memberId), Future<void> transferOwnership(from, to), Future<void> removeMember(memberId) — each calls authorizer.require(...) first and enforces the zero-last invariants (throws LastOwnerCannotDeleteWithMembers / LastAdminCannotDemote).

  • Step 1 — failing tests: revoking the last owner of a multi-member household throws LastOwnerCannotDeleteWithMembers; a non-owner calling grantOwner throws AuthorizationFailure; transferOwnership leaves ≥1 owner. await expectLater(svc.revokeOwner(last), throwsA(isA<LastOwnerCannotDeleteWithMembers>())).
  • Steps 2–5. Mirror the guard style of the existing ledger zero-floor service check.

Task 10: createHousehold bootstrap grant

Files: Modify household_service.dart; Test same file. Produces: createHousehold grants the creator owner=true + role=admin atomically with the household row (the one path that precedes a member record — an explicit, audited exception to require).

  • Step 1 — failing test: after createHousehold, the creator member is owner && roles.contains(admin) && status==active. Steps 2–5.

Task 11: wire require(...) across existing mutating methods

Files: Modify the mutating methods in household_service.dart, chore_service.dart, economy_service.dart, approval_service.dart (and the facade) to open with authorizer.require(actor, <capability>). Map each method → capability per §2.

  • Step 1 — failing tests: a member-kind actor calling createChore throws AuthorizationFailure; a helper calling approveSpendRequest throws. (One representative test per capability class.) Steps 2–5. RLS-parity: confirm the matching policy from Phase 2 denies the same op.

Phase 3 review: flutter-reviewer + security-reviewer. Gate: every mutation guarded, zero-last holds in service AND (Phase 2) schema, bootstrap audited.


Phase 4 — ConsentService

Task 12: ConsentRecord + TermsAcceptance models + adapters

Files: Create consent_record.dart, terms_acceptance.dart; modify adapters + Drift schema for the consents/terms_acceptances/member_access tables. Test round-trip.

  • Standard model+mapper TDD (mirror Task 8). Commit regenerated .g.dart (scoped + restore siblings).

Task 13: capture + child-PII write gate (online)

Files: Create consent_service.dart; modify the facade to expose it. Test test/services/consent_service_test.dart. Produces: Future<ConsentRecord> captureConsent({memberId, method, tosVersion, privacyVersion}) (writes pendinggranted appropriately, requires captureConsent capability), Future<void> revokeConsent(memberId) (→ revoked + eraseChildData cascade), and the gate: any child-PII write path asserts consentState==granted else throws ConsentRequired.

  • Step 1 — failing tests: writing a child goal with consentState==none throws ConsentRequired; after captureConsent→granted it succeeds; revokeConsent flips to revoked and re-blocks. await expectLater(...).
  • Steps 2–5. RLS-parity: the Phase 2 child-PII trigger rejects the same direct write.

Task 14: offline Drift gate + revocation tombstone

Files: Modify consent_service.dart + the local adapter. Test test/services/consent_offline_test.dart. Produces: the gate reads local Drift consent_state before any local child-PII write; a revoke while offline tombstones queued child writes (marks them dropped) and reconciles on sync.

  • Step 1 — failing tests: with the in-memory/Drift local store and no network, a child-PII write under none throws ConsentRequired; revoke-offline tombstones a pending child write. Steps 2–5.

Files: Modify consent_service.dart. Test test/services/consent_lifecycle_test.dart. Produces: Future<void> acceptTerms({tosVersion, privacyVersion}), bool termsAcceptanceRequired(account), Future<void> checkConsentCurrency(member, currentVersions) (→ granted→pending on a material change; sets deadlineAt — the mechanism; the 14-day/85% numbers are ⚖️ deferred), post-erasure retention (on member delete, null member_ref on the consent row but keep the event), and shadow-member ToS ordering (admin accepts at creation; shadow accepts own on activation).

  • Step 1 — failing tests: termsAcceptanceRequired true until acceptTerms with the current version; checkConsentCurrency flips a stale grantedpending; deleting a child nulls member_ref but the consent row remains. Steps 2–5.

Phase 4 review: security-reviewer + database-reviewer. Gate: gate holds online AND offline, append-only retained, no child PII leaked on erasure.


Phase 5 — Supervised action

Task 16: actingMemberId on the approval path

Files: Modify approval_service.dart (+ approval.dart/chore_submission.dart if they need actingMemberId/principalAccountId). Test test/services/supervised_action_test.dart. Produces: submit/approve carry actingMemberId (child) ≠ principalAccountId (parent); superviseAction required of the approving parent; reuses the built autoApproveCompletion (auto) and the manual queue — no new earn path. Earn routes by consent state (own wallet if granted, else household envelope — the household-envelope credit itself is ②; here assert the routing decision/exception, not the envelope write).

  • Step 1 — failing tests: a supervised submit on a manual chore needs a parent approve carrying approvedByMemberId; on an auto chore it resolves via autoApproveCompletion with resolvedBy=auto_policy and no per-action tap; a non-parental actor calling superviseAction throws. Steps 2–5.

Phase 5 review: flutter-reviewer. Gate: single token-moving path preserved (no bypass); manual + auto both covered.


Phase 6 — Authz observability

Task 17: the five authz events

Files: Create packages/client_sdk/lib/src/services/authz_events.dart (emit through the existing analytics seam — find it: graphify query "analytics telemetry event emit bucket"); wire emit points in Authorizer.require, ConsentService, ApprovalService. Test test/services/authz_events_test.dart. Produces: authz_decision, authz_escalation_blocked, consent_state_changed, child_pii_write_rejected, supervised_action_committed — payloads carry opaque household-scoped IDs only, tagged internal-BI.

  • Step 1 — failing tests: a denied require emits authz_escalation_blocked whose payload contains no member name/email (only an opaque id) and a bucket=internalBI tag; a child-PII rejection emits child_pii_write_rejected. Assert the pre-emit scrub drops any child identifier. Steps 2–5.

Phase 6 review: security-reviewer. Gate: zero child identifier in any payload; correct bucket tag.


Phase 7 — App-UI surfaces

Mirror the existing inside/outside + bloc + auto_route patterns (read graphify query "bloc repository page auto_route guard" and an existing editor like the chore editor). Each task: repository → bloc → page → flow test (mock repositories via the flow-test harness; see app/test/README.md).

Files: Create app/lib/outside/repositories/authz_repository.dart, consent_repository.dart (thin delegates to the SDK facade — no domain logic). Test: mock at this seam in later flow tests.

  • Standard repository TDD. Commit.

Task 19: role & owner management (Admin → Members)

Files: Create the bloc + page; wire the route. Test app/test/flows/role_owner_management_test.dart. Produces: assign role; grant/revoke owner (≥1 guard surfaced as a blocked action with LastOwnerCannotDeleteWithMembers message); transfer ownership.

  • Step 1 — failing flow test: tapping "remove owner" on the last owner shows the guard message; assigning helper updates the row. Steps 2–5.

Files: Create the consent bloc + page + the session-start ToS gate. Test app/test/flows/consent_lifecycle_test.dart. Produces: start VPC (email-plus/card), show none→pending→granted→revoked, revoke; block the session until acceptTerms.

  • Step 1 — failing flow test: session blocked until ToS accepted; capturing consent moves the chip to granted. Steps 2–5.

Task 21: supervised-action UX

Files: Create the supervised flow widget(s). Test app/test/flows/supervised_action_test.dart. Produces: child picks a bounty / signs off on a shared device; manual → parent approve prompt; auto → silent resolve.

  • Step 1 — failing flow test: manual chore shows the parent-approval prompt; auto chore resolves with no prompt. Steps 2–5.

Task 22: capability-aware route guards + badges

Files: Modify the auto_route guards + the More/Settings badges to call authorizer.can(...) (not raw isParental). Test app/test/flows/route_guard_capability_test.dart. Produces: Admin hub gated by capability; a non-parental helper still sees the approval badge (the bug §2 fixes).

  • Step 1 — failing flow test: a helper sees the approval-queue badge; a plain member is denied the Admin route with the "no permission" surface. Steps 2–5.

Phase 7 review: flutter-reviewer + a11y check on the new surfaces. Gate: guards use can(...), helper badge visible, no raw isParental in guard files.


Phase 8 — Integration, parity, docs

Task 23: full sweep + flip status + graphify

  • Run the whole suite (packages/client_sdk + app); assert ≥ baseline (compare to /tmp/baseline_*); green fvm dart analyze.
  • Run the guarded live RLS-parity smoke against bgedvvmihygwxhjxlvfu (creds flag), extending the SP3 smoke: cross-household isolation + helper-cannot-approve-spend + child-PII-gate.
  • Edit authorization.md: flip the caution banner + the proposed → built lines for the now-built pieces; redeploy the dev site (scripts/test_gallery_serve.sh + scripts/deploy-developer-gallery.sh from repo root).
  • graphify update .; commit graph.json + GRAPH_REPORT.md.
  • Commit. Final whole-branch review (flutter-reviewer + database-reviewer + security-reviewer).

Self-review (run after writing — completed)

  • Spec coverage: P1↔Authorizer/capabilities; P2↔schema/RLS/helpers/triggers; P3↔guards/owner/role/zero-last/bootstrap; P4↔consent online+offline+ToS+lifecycle; P5↔supervised; P6↔events; P7↔UI surfaces+guards; P8↔integration/parity/docs. Every spec phase + the §7 coverage items map to a task. ✓
  • Placeholders: the ⚖️ SLA numbers are explicitly deferred (spec out-of-scope), not a plan gap; no TBD/"handle edge cases". ✓
  • Type consistency: Authorizer.require/can/capabilitiesFor, Capability, ConsentState, MemberAccess, the new exceptions are named identically across Tasks 1–22. ✓