State machine · verified · cross-table encoding

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?

Last verified 2026-04-17 against code
Slug consent-lifecycle
State encoding multi-table (⚠ not a single column)
Tables involved 5

§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)

flowchart LR classDef unver fill:#f1eee7,stroke:#6b6455,color:#333 classDef pend fill:#fff4d6,stroke:#a47a3a,color:#111 classDef active fill:#e8f6ec,stroke:#2f7a3e,color:#111 classDef rev fill:#fdecea,stroke:#b8432e,color:#111 classDef arch fill:#eaeaec,stroke:#6b6455,color:#555 classDef del fill:#d6d2c7,stroke:#2a2a28,color:#111 Signup([parent signs up]) Signup --> U[Unverified
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.

TransitionOwnerCode
Signup → Unverifiedsignup handlerFuzzyCode/quart_server/blueprints/auth.py (signup routes)
Unverified → Pending (add child)pending-consent writerFuzzyCode/quart_server/services/coppa_consent.py:699+ (upsert_pending_parental_consent)
Pending → Verified ActiveStripe 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 → Restrictedbilling reconcileservices/billing_state.py · system_jobs.py
Active → Revokedparent-rights request executionservices/parent_rights.py (revoke flow)
Revoked → Archived (90d)scheduled archive sweepservices/system_jobs.py:33 (POST_CANCELLATION_ARCHIVE_DAYS=90)
Archived → Permanently Deletedparent-rights executionservices/parent_rights.py:1346-1421 (permanent_delete_child_account)
Active → Restricted (soft delete info)parent-rights executionservices/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)

1. "Verified" is not a single value. It is the absence of a pending flag and the presence of two different recorded-consent rows (parent account verification + specific-child private use). Mirrors of the "verified" state in the UI often leak when one of those is missing but the other is present.
2. Aspirational doc vs reality. Several WIP COPPA docs (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.
3. TTL cleanup is a manual script. 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.
4. archive_expires_at is 90 days hard-coded. POST_CANCELLATION_ARCHIVE_DAYS=90 in system_jobs.py:33. Policy changes require a code change.
5. There is no "soft-verified" state. A child is either in the pending bucket or fully active. There is no "partial" state visible to UIs; if a parent completes consent but Stripe checkout hasn't landed, the child still shows pending.

§7 Verification pointers