Parental Consent Lifecycle
When the docs say a child moves from unverified → pending → verified → revoked → archived, what does that actually look like in the database — and how do you query for it correctly?
§1 Why this diagram is different
The common doc framing is a tidy five-state lifecycle. The database disagrees. There is no consent_state column anywhere. The lifecycle is expressed by the combination of five different enums (and one boolean) across five different tables. Each transition updates some of them, not all.
⚠ Reading the matrix is not optional
A query like SELECT * FROM children WHERE child_status = 'active' does not give you "verified active children." You also need parental_consents scoped as specific_child_private_use with consent_status = 'recorded', and the child's household household_status = 'active', and the login account login_state = 'active'. Miss any of them and you ship a COPPA-relevant bug.
§2 The lifecycle stages (as users and auditors think of them)
parent account exists;
no child yet] U -->|create child account| P[Pending
direct notice sent;
awaiting consent action] P -->|parent completes
consent + payment| A[Verified Active
child can use platform] P -->|TTL expires
no action| DEL[Deleted
cleanup script] A -->|parent revokes| R[Revoked
logins blocked] A -->|payment lapses| RE[Restricted
grace or past-due] RE -->|catch up| A RE -->|cancellation window
expires| ARC[Archived
90d retention window] R -->|90d| ARC ARC -->|parent triggers
permanent delete| DEL class U unver class P pend class A active class R rev class RE rev class ARC arch class DEL del
This view is how the COPPA docs and a human auditor would describe the lifecycle. It's useful — but it's not how the code sees state. The code sees §3.
§3 How each stage maps to the database
Rows are the actual columns / booleans across five tables. Columns are the stages from §2. A cell shows what each column holds during that stage.
| Unverified | Pending | Verified Active | Revoked / Restricted | Archived | |
|---|---|---|---|---|---|
| households | |||||
| household_status | pending_verification | pending_verification | active | grace_past_due or restricted | archived |
| children | |||||
| child_status | no row yet | pending_private_use | active | restricted | archived → deleted |
| archived_at | — | NULL | NULL | NULL | <timestamp> |
| first_name / last_name / birthday | — | stored | stored | stored | nulled on delete |
| child_auth_accounts | |||||
| login_state | no row yet | pending | active | disabled | archived |
| auth_user_id / private_auth_slug | — | stored | stored | stored | nulled on permanent delete |
| parental_consents (scoped rows) | |||||
| scope = parent_account_verification | no row | recorded | recorded | recorded | recorded (historical) |
| scope = specific_child_private_use | — | no row yet | recorded | revoked | revoked (historical) |
| revoked_at (on revoked rows) | — | — | NULL | <timestamp> | <timestamp> |
| private_profiles | |||||
| pending_parental_consent | true | true | false | false | false |
| pending_parental_consent_expires_at | now + 14d | now + 14d | NULL | NULL | NULL |
| parental_consent_date | NULL | NULL | <timestamp> | <kept, historical> | <kept, historical> |
§4 Who writes what
Each transition is owned by a specific handler. A bug in any one of these leaves the database in a partial state where the matrix above no longer lines up cleanly.
| Transition | Owner | Code |
|---|---|---|
| Signup → Unverified | signup handler | FuzzyCode/quart_server/blueprints/auth.py (signup routes) |
| Unverified → Pending (add child) | pending-consent writer | FuzzyCode/quart_server/services/coppa_consent.py:699+ (upsert_pending_parental_consent) |
| Pending → Verified Active | Stripe webhook (completion) | supabase/functions/stripe-webhook/index.ts:862 — sets parental_consent_date |
| Pending → Deleted (TTL expired) | manual script (dry-run default) | FuzzyCode/scripts/cleanup_pending_parental_consent_accounts.py |
| Active → Restricted | billing reconcile | services/billing_state.py · system_jobs.py |
| Active → Revoked | parent-rights request execution | services/parent_rights.py (revoke flow) |
| Revoked → Archived (90d) | scheduled archive sweep | services/system_jobs.py:33 (POST_CANCELLATION_ARCHIVE_DAYS=90) |
| Archived → Permanently Deleted | parent-rights execution | services/parent_rights.py:1346-1421 (permanent_delete_child_account) |
| Active → Restricted (soft delete info) | parent-rights execution | services/parent_rights.py:1369-1381 (delete_child_information) |
§5 Query cookbook
The correct way to query each conceptual stage. Copy these rather than inferring.
"All verified active children"
-- parent has verified (household active + has parent_account_verification) -- AND child has private-use consent recorded -- AND child row is active AND child auth account is active SELECT c.* FROM children c JOIN households h ON h.id = c.household_id JOIN child_auth_accounts ca ON ca.child_id = c.id JOIN parental_consents pc ON pc.child_id = c.id WHERE c.child_status = 'active' AND h.household_status = 'active' AND ca.login_state = 'active' AND pc.scope = 'specific_child_private_use' AND pc.consent_status = 'recorded';
"Children pending parental consent, close to expiring"
SELECT c.id, pp.pending_parental_consent_expires_at FROM children c JOIN private_profiles pp ON pp.child_id = c.id WHERE c.child_status = 'pending_private_use' AND pp.pending_parental_consent = true AND pp.parental_consent_date IS NULL AND pp.pending_parental_consent_expires_at < now() + interval '48 hours';
"Revoked children still within the 90-day archive window"
-- both "revoked by parent" and "restricted by billing lapse" surface here -- because both manifest as parental_consents.consent_status='revoked' OR -- child_status='restricted' — see source handlers in §4 SELECT c.* FROM children c WHERE c.child_status IN ('restricted', 'archived') AND (c.archived_at IS NULL OR c.archived_at > now() - interval '90 days');
§6 Gotchas (from the verification sweep)
parent-child-account-lifecycle-state-analysis.md, etc.) describe a unified state machine that does not exist. ~30% of the 77 WIP COPPA docs describe shipped behavior; ~40% partially; ~30% pure forward-looking. Treat each WIP doc's Status: header as authoritative.
cleanup_pending_parental_consent_accounts.py is dry-run by default and has to be run with --apply. No cron is wired in this repo.
archive_expires_at is 90 days hard-coded. POST_CANCELLATION_ARCHIVE_DAYS=90 in system_jobs.py:33. Policy changes require a code change.
§7 Verification pointers
- parental_consents table + CHECK
supabase/migrations_after_baseline/20260330190000_add_household_child_foundation.sql:113-152 - households enum
20260330190000_add_household_child_foundation.sql:8-28 - children enum
20260330190000_add_household_child_foundation.sql:55-87 - child_auth_accounts enum
20260330190000_add_household_child_foundation.sql:235-275 - pending_parental_consent flag
20260323_000000_add_pending_parental_consent_flow.sql:9 - Pending writer
FuzzyCode/quart_server/services/coppa_consent.py:699+ - Pending cleanup
FuzzyCode/scripts/cleanup_pending_parental_consent_accounts.py - Stripe webhook → verified
supabase/functions/stripe-webhook/index.ts:862 - Archive sweep
FuzzyCode/quart_server/services/system_jobs.py:33 - Parent rights: revoke / archive
services/parent_rights.py:1346-1421, 1572-1578 - Private-use consent write
FuzzyCode/quart_server/services/child_private_use.py:265-319 - Parent-rights verification state
20260407183000_add_parent_rights_structured_verification_fields.sql