Skip to main content

Third-Party Processor Inventory

Owner: Platform / Compliance Lane: R4.5 (COPPA / VPC product-flow + PII processor cleanup) Last reviewed: 2026-08-28 (SEC-01 pass — corrected the Upstash row's What we send cell, which still claimed RAW CLIENT IP ADDRESSES "on most routes" and that base-handler imports rate-limit-key ZERO times. Both were true when written on 2026-08-08 and FALSE after b2392165 (2026-08-24): BaseApiHandler now imports the module and digests every identifier before any limiter call, verified at source and against the live production key shape (32-hex digest, scope-prefixed since 3623d683). The stale cell over-disclosed — a reviewer would have re-fixed a closed bug or mis-scoped a deletion request off it. Prior review 2026-08-23, SENTINEL — corrected the Upstash row's Retention and Deletion API / SLA columns, which still described only the counter keyspace and therefore contradicted this same row's own "What we send" cell about the never-expiring analytics keyspace. Verified in the same pass, against production Sentry telemetry, that purge-audit-logs and purge-error-logs now run daily and return ok:true — CRON_SECRET was set in Vercel Production ~2026-08-21, so the pre-08-21 statements that "no purge job has ever run" are stale. Prior review 2026-08-06, SENTINEL F-1/F-2 — corrected the Upstash and Vercel Analytics rows; BOTH were inaccurate at the time of review, describing controls that did not match the code. Prior review 2026-05-24, CRIT-3 reconciliation.)

This document is the authoritative list of every third-party processor that touches AI Safety Adventures user data. It is referenced from the privacy policy (R4.6) and from docs/SECURITY-ACCEPTED-TRADEOFFS.md. Update this file whenever a processor is added, removed, or its data scope changes.

The 2025 COPPA Rule (90 FR 16977, Apr. 22, 2025; compliance deadline Apr. 22, 2026) requires that parents consenting to data collection have access to a clear list of every operator and service-provider that may receive their child's data. This file IS that list.


What we mean by "kid data"

For COPPA purposes, "kid data" = any datum tied to a kid_account row or its student_token_id. Specifically:

  • kid_accounts.username (a machine-generated handle; pseudonymous, not anonymous — a first-party persistent identifier is personal information under 16 CFR §312.2(7). This line said "anonymous handle, not PII by COPPA's definition" until 2026-08-28, contradicting the privacy page's own corrected language; SENTINEL caught it in the SEC-01 gate.)
  • kid_accounts.credential_hash (never sent to processors in plaintext)
  • student_tokens.id (universal player identity — sent to processors as a sha256 prefix or full UUID depending on the processor)
  • gameplay events (XP, packs, scenario progress) tied to a student_token

Adult parent PII (parent_accounts.email, parent_accounts.password_hash) is NOT kid data but is governed by the same disclosure requirements.


Processor matrix

ProcessorWhat we sendRetentionDeletion API / SLADPA statusDocumented in COPPA notice
Sentry (sentry.io)studentId UUID, request URL, stack trace; NO request body after R0.2 (event.request.data stripped). event.user/extras filtered by beforeSend (R4.7) for email/pin/password/studentToken/parentEmail/credential keys → [REDACTED]. Replay disabled on kid routes.90 days default (Pro tier configurable)DELETE /api/0/projects/{org}/{proj}/issues/?id={id} — programmatic per-issue purge. Bulk purge by tag (studentId) supported. SLA: 30 days for parent-initiated deletion (R4.13).DPA on file (date TBD — see TODO below).yes
Resend (resend.com)Parent email (recipient), one-time kid login token (post-R4.8 — never the kid's plaintext PIN/emoji), email subject, html/text body. Logs hashed-recipient only after R4.9.Per Resend retention (default 30 days for sent-email log)DELETE /emails/{id} for individual emails; bulk deletion via support ticket. SLA: 30 days for parent-initiated deletion (R4.13).DPA on file (date TBD).yes
Vercel (hosting + logs)Request paths, response status, and the raw client IP and user-agent. Body NOT logged (no Vercel-side body capture configured). CORRECTED 2026-08-08: this row previously said "hashed IP/UA via x-forwarded-for/x-real-ip extraction". That was false and conflated OUR control with the PROCESSOR's exposure — Vercel terminates the connection at the edge, upstream of every line of our code, so it observes the raw address no matter what we hash afterwards.30 days — this project is on the HOBBY tier (confirmed by Mike, 2026-08-08). Recorded because nobody had ever written the tier down, and the row previously said only "90 days for Pro tier; 30 days for Hobby", which left the actual retention figure unresolvable from this repo. app/privacy/page.tsx publishes 30 days and is therefore CORRECT. If the project ever moves to Pro, that page understates retention in a legal document and must change in the same deploy.No programmatic log-deletion API for Hobby/Pro tier; manual ticket required. SLA: 30 days for parent-initiated deletion (R4.13) — manual purge documented.Covered by Vercel DPA on platform agreement.yes
GitHub Actions (CI artifacts)NONE as of R0.1 (2026-04-30) — DB backup artifact upload disabled. Scheduled-purge workflow logs carry counts only (the purge routes return {deleted, errors, ...} summaries, no identifiers or row content — verified 2026-08-31). One caveat: the manual-trigger fetch-vercel-logs.yml pulls Vercel logs (which contain raw client IPs), sanitizes, and uploads a 7-day artifact; its IP redaction is IPv4-only, so an IPv6 client address can pass into the artifact (ticketed, WQ-VERCEL-LOG-SANITIZER-MISSES-IPV6). Since 2026-08-31 GitHub Actions also holds CRON_SECRET and is the sole scheduler for the daily COPPA retention purges (.github/workflows/cron-jobs.yml) — no user data is transmitted, but availability of that retention control now depends on GitHub.Per GH Actions retention; log artifacts 7 daysn/a (no kid data sent by scheduled jobs)n/an/a
Upstash Redis (rate limiting)Rate-limit keys and counts only — since b2392165 (2026-08-24), no raw identifier leaves the process on any limiter path. BaseApiHandler imports lib/api/rate-limit-key and digests the client IP (rateLimitKey('client', …) → truncated 32-hex SHA-256) before every limiter call; every Upstash-bound key — network-fuse, per-principal, and per-IP — derives from that digest or from a session-derived principal digest (grep hashedClientIp in lib/api/base-handler.ts and read the ladder block; line numbers deliberately NOT pinned, per CBI-025's companion lesson). Since 3623d683 (2026-08-26) a route may prefix its key with a scope segment (<scope>:<digest>) so same-tuple routes stop sharing buckets — the live production key shape (@upstash/ratelimit:<requests>:<window>:<scope>:<32-hex>:<bucket>) was observed directly and confirms the digest. Adult limiters digest the email (email-rate-limiter.ts, teacher-login-limiter.ts). Child login limiters digest the client IP joined with an account identifier — (ip, username) for kid login, (ip, teacherCode, studentNumber) for student PIN — via the same module. The digest is NOT anonymization: it is unsalted and unkeyed, and the IPv4 space is exhaustively searchable, so a digest of an IP must be treated as personal information equivalent to the address. See WQ-IP-HASH-NOT-A-CONTROL, and the header of rate-limit-key.ts, which forbids the words "anonymized" / "irreversible" / "one-way" about this digest in any policy document. History, kept because data may still exist at the processor: (1) until b2392165, this handler transmitted RAW client IPs as key material on most routes (the 2026-08-08 correction below described that state truthfully) — those counter keys were TTL-bounded (≤3600s windows on this path) and have long expired; (2) the limiter ran with analytics: true until 2026-08-08, and @upstash/core-analytics sets no TTL at all, so its usage record accumulated raw addresses indefinitely — analytics: false (2026-08-08) stops new writes; it deletes nothing, so the pre-08-08 sets still hold raw child IPs and the explicit purge is still required. See WQ-UPSTASH-ANALYTICS-RETAINS-RAW-CHILD-IPS. Row provenance: until 2026-08-28 this cell still described the pre-b2392165 raw-IP state ("RAW CLIENT IP ADDRESSES on most routes", "base-handler.ts imports the rate-limit-key module ZERO times", 182 declarations / 156 files) — true when written, false for the four days after the fix shipped; corrected in the SEC-01 pass.Two disjoint keyspaces, do not state one figure for both. (1) COUNTER keys: TTL'd at insert, 24h max for auth limits — the longest is the 86400s parent-link window/block in kid-rate-limiter.ts; every BaseApiHandler window is ≤3600s. (2) ANALYTICS keys: no TTL at all, ever — @upstash/core-analytics@0.0.10 calls no expire/pexpire. Writes stopped 2026-08-08 (analytics: false); the pre-existing sets are still there and still unexpiring. This cell said only "TTL'd at insert (24h max for auth limits)" until 2026-08-23, which was true of (1) and false of (2), and contradicted this row's own "What we send" cell.Key deletion via Upstash REST API. SLA: 30 days for parent-initiated deletion (R4.13). Counter keys are TTL-bounded so naturally expire; analytics keys are not and require the explicit SCAN MATCH "@upstash/ratelimit*events:*" purge described under "Data-deletion fan-out" below — it has not been run. NOTE: since b2392165 (2026-08-24) every key on this processor is a digest, so deleting one specific child's key requires recomputing the digest from the raw values; the raw-IP keys that predate the fix carried ≤3600s TTLs and have expired on their own.DPA on file (date TBD).yes
Stripe (payments)Teacher email, customer ID, subscription state, payment method metadata. NO kid data — Stripe is teacher-tier only.Per Stripe retention (varies by data class)Account-level deletion via Stripe dashboard. Kid data: n/a.DPA on file (date TBD).n/a (teacher-only)
Neon Postgres (DB host)Full app data (encrypted at rest by Neon). Includes kid_accounts, student_tokens, parent_accounts, audit logs.Per backup policy (configurable, default 7 days point-in-time)Account deletion + branch deletion. SLA: 30 days for full-data wipe via parent request (R4.13).DPA on file (date TBD).yes
Google OAuth (teacher login)Teacher email, OAuth access/refresh tokens, profile picture URL. NO kid data.Per Google retentionAccount-level only (revoke OAuth grant on Google's side).Covered by Google ToS / DPA on the developer agreement.yes
Microsoft Graph (teacher integration)Teacher tenant data, OAuth tokens, optional roster sync data (student usernames; configurable).Per Microsoft retentionAccount-level only (revoke app consent in Azure AD).Covered by Microsoft DPA on the developer agreement.yes
Google Classroom API (classroom.googleapis.com/v1)Teacher OAuth scope (classroom.courses.readonly, classroom.rosters.readonly), classroom roster data (student display-names as provided by school SIS), course IDs. Teacher-initiated opt-in; no continuous background sync. Wired but feature is WIP — not yet tested in production.Roster data persisted only as part of the classroom record (governed by classroom retention SLA).Account-level only (teacher revokes OAuth grant on Google's side). Roster data deletes with the classroom record per R4.13.Covered by Google ToS / DPA on the developer agreement (same as Google OAuth row).yes
Vercel Blob (@vercel/blob)Teacher community-resource file uploads (PDF, images, etc.) — file contents + uploader teacher ID + content-type. NO kid uploads. Used by app/api/community/upload/route.ts.Retained for the lifetime of the community resource (admin-curated).Blob deletion via Vercel Blob REST API. SLA: 30 days for teacher-initiated deletion.Covered by Vercel DPA on platform agreement.yes
Vercel Analytics + Speed Insights (@vercel/analytics, @vercel/speed-insights)Page paths + anonymized session metrics (page views, web vitals, geolocation country-level). Gated by an ALLOWLIST in app/layout.tsx — loaded ONLY on explicitly-listed adult/marketing routes (/, /v2, /for-parents, /for-teachers, /scenarios, /steam-night, /privacy, /terms, /tools, /auth, /dashboard, /admin) and OFF everywhere else by default, including on an unknown or missing pathname. NO kid data. — CORRECTED 2026-08-06 (SENTINEL F-2): this row previously described a DENYLIST of /demo, /student/*, /play/* and claimed telemetry loaded "only on marketing + teacher-dashboard surfaces". That was false. /play-free/* — the anonymous, no-signup children's play flow — did not match /play or /play/, so all three components loaded there, and the gate additionally failed OPEN when the pathname header was absent. /enroll, /join, /join-student, /parent and /debug were also uncovered. The allowlist fixes the class; app/__tests__/layout.test.tsx now pins every case.Per Vercel Analytics retention (configurable, default 90 days).No programmatic deletion of aggregate analytics; per-event is anonymous and not parent-keyed.Covered by Vercel DPA on platform agreement.yes (scope explicitly disclosed)

DPA TODO list

The following processors need a DPA renewal / first-time signature verified before launch. R4.10 / R4.13 close blocks on this list.

  • Sentry — confirm DPA effective date in Sentry org settings
  • Resend — verify DPA on file at resend.com/legal
  • Upstash — verify DPA on file at upstash.com/legal
  • Stripe — confirm DPA effective date for AI Safety Adventures account
  • Neon — verify DPA on file at neon.tech/legal

These are date-confirmation items, not "we don't have a DPA" items. Each processor publishes a standard COPPA-compatible DPA; we just need the date persisted for audit purposes.


Data-deletion fan-out (R4.13)

When a parent deletes their account or revokes consent, the following fan-out runs in lib/coppa/data-deletion.ts:

  1. DB cascade — kid_accounts, student_tokens, parent_link_requests, gameplay tables (xp, packs, etc.) all cascade-delete via FK ON DELETE CASCADE. SLA: synchronous.
  2. Sentry — purge events tagged with studentId via the issues API. SLA: queued + completes within 30 days.
  3. Resend — purge logs by recipient_hash (sha256 of normalized email). SLA: queued + completes within 30 days.
  4. Vercel logs — file a support ticket. Manual SLA: 30 days, documented in privacy notice.
  5. GitHub Actions — n/a (no artifacts retained per R0.1).
  6. Upstash — the rate-limit COUNTER keys are TTL'd. CORRECTED 2026-08-08: "no explicit purge needed" is false. The limiter previously ran with analytics: true, and @upstash/core-analytics never calls expire/pexpire, so the usage record — which contains raw client IPs — accumulated with no TTL. analytics: false shipped 2026-08-08 (grep analytics: in lib/api/rate-limiter.ts), which stops NEW writes but deletes nothing. The sets already written are still there and still have no expiry, so a purge is still required. Key shape: @upstash/ratelimit:<requests>:<window>:events:<hourBucket>, one key per hour per limiter config — and the purge must ALSO cover the legacy flat prefix @upstash/ratelimit:events:*, which is where everything written before the per-route namespacing (v2i-59) lives. SCAN MATCH "@upstash/ratelimit*events:*" covers both. See WQ-UPSTASH-ANALYTICS-RETAINS-RAW-CHILD-IPS.
  7. Stripe — kids never have Stripe records; teacher record stays per teacher's own deletion request.
  8. Neon — point-in-time backup retention is 7 days; the deletion propagates to backup snapshots within that window.
  9. Google / Microsoft OAuth — revoking the OAuth grant on the IDP side is the user's responsibility; we documenting the path in the privacy notice.

Total worst-case SLA for full kid-data-trace deletion: 30 days from parent revoke event to confirmation.


Re-evaluation triggers

  • Any new processor added to the stack → add a row above + run a privacy policy refresh in the same PR.
  • Any processor's retention period changes upstream → update the row + flag in docs/SECURITY-ACCEPTED-TRADEOFFS.md if SLA shifts.
  • A future COPPA Rule amendment from FTC → audit the entire matrix for conformance.
  • Annual review at the launch anniversary (calendar event recommended).