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
| Processor | What we send | Retention | Deletion API / SLA | DPA status | Documented 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 |
Cloudflare R2 (r2.cloudflarestorage.com, bucket aisa-db-backups) — row added 2026-10-18; all four open questions (retention, removal mechanism, DPA, timeframe) answered by Mike and written in 2026-10-19. Row provenance, 2026-10-19 — read this before quoting the next two columns. The "bucket is empty" statement is Mike-reported: he deleted the bucket's contents by hand in the Cloudflare dashboard on 2026-10-19 and reported it the same day. No tool in this repo read the bucket to confirm it, and there are no R2 credentials in this working tree, so nothing here rests on an automated or independently verified check. Earlier the same day this row said the objects were "due for deletion" but not confirmed gone; that hedge is superseded by the hand deletion, not by a measurement. The bucket itself was NOT deleted and its lifecycle rule is still in force. | A full nightly pg_dump --format=plain of the WHOLE production database, including kid-linked tables (student_tokens, student_sessions, audit_logs, kid_accounts) and parent/teacher account tables. Uploaded nightly between 2026-07-27 and 2026-09-11 (first upload run 30229877433, 2026-07-27T01:28:57Z; last run 2026-09-11). Encrypted at rest by Cloudflare (AES-256, Cloudflare-managed keys). Suspended 2026-09-11: the workflow is disabled_manually and the upload step is gated if: ${{ false && … }}; no new objects since. Those objects were disclosed first (Mike's decision, 2026-10-18) and then deleted by hand on 2026-10-19; the bucket is empty and the 30-day lifecycle rule set the same day now governs any future object instead. (This cell said "were never purged by hand" until that deletion.) | The bucket is empty today; the 30-day rule remains in force for any future object. Mike set the bucket's lifecycle rule "Delete uploaded objects after" to 30 days on 2026-10-19 (Cloudflare dashboard, bucket aisa-db-backups) and deleted the bucket's contents by hand the same day. Every dump written 2026-07-27..2026-09-11 is gone and the bucket holds no objects (Mike-reported — see the row-provenance note; nothing in this repo measured it). The rule was not removed and still matters: the backup workflow can be re-enabled, and any object written after that expires 30 days after its own upload date. For the entire time the job ran (2026-07-27..2026-09-11) no expiry rule was in force — the provisioning runbook had deferred one to a later audit (docs/runbooks/db-backup-r2-provisioning.md §6) and it was never applied — so every dump written in that window persisted until the hand deletion: the oldest for 84 days (2026-07-27 to 2026-10-19), 54 days longer than a 30-day rule would have allowed. The bucket's separate "Default Multipart Abort Rule" (7 days) is NOT a retention period — it aborts INCOMPLETE multipart uploads only and never deleted a completed dump; "backups are deleted after 7 days" would be a false retention claim and must not be written anywhere. | Nothing is held here today, so there is nothing for a deletion request to reach: the objects written 2026-07-27..2026-09-11 were deleted by hand on 2026-10-19 (Mike-reported; see row provenance). The gap is empty, not closed — it returns the moment the backup job is re-enabled, because the R4.13 fan-out in lib/coppa/data-deletion.ts still has no R2 adapter: runDeletionFanOut calls six adapters (sentry, resend, vercel_logs, github_artifacts, upstash, neon_backups) and none touches R2. So if backups resume, a deletion honoured in the database will not be applied to a backup object. That object would be removed one of two ways, neither automatic on a parent request. (1) Expiry: the 30-day lifecycle rule deletes it 30 days after its upload date, independently of anything in our database. (2) Manual: deletion via the Cloudflare dashboard or the S3-compatible API, on request. Worst case while the job stays off: nothing is retained. Worst case if it resumes: 30 days from the object's upload date — inside the 30-day R4.13 SLA published for the other processors, but reached by expiry rather than by propagation, and not conditional on anyone acting on the request. Do not re-enable the job without re-reading the "Disclosure status" section of docs/runbooks/db-backup-r2-provisioning.md: condition (d) is disclosed, not fixed. | In force, incorporated by reference; no signature required (confirmed by Mike, 2026-10-19, from the primary sources). Cloudflare's Self-Serve Subscription Agreement §6.1 states that the Data Processing Addendum "is hereby incorporated by reference into this Agreement", and the agreement is accepted by using the Services (cloudflare.com/terms/, last updated 2025-09-12). Cloudflare's GDPR FAQ (cloudflare.com/trust-hub/gdpr/) states the same — "incorporated by reference into our Self-Serve Subscription Agreement", "no action is required" — and records that the DPA incorporates the EU SCCs. The operative document is the Customer DPA version 6.4, effective 2026-04-03 (cloudflare.com/cloudflare-customer-dpa/), which predates and therefore covers the whole 2026-07-27..2026-09-11 backup window. Reconciliation, recorded so this is not re-opened: the DPA's own effective-date clause reads "the date on which Customer signed or the parties otherwise agreed to this DPA", and accepting the self-serve agreement that carries §6.1 is the "otherwise agreed" path. Nothing is countersigned and nothing is expected in business/legal/. (Cloudflare's support chatbot volunteered a similar conclusion in wording that appears nowhere on the DPA page; it is not the basis for this cell — §6.1 and the GDPR FAQ are.) | yes — app/privacy/page.tsx, "Third-Party Services (Sub-Processors)", final paragraph; that paragraph reaches production in the same promotion as this row |
| GitHub Actions (CI artifacts) | Backup job, transient: from 2026-07-27 to 2026-09-11 the nightly backup workflow ran pg_dump of the production database on a GitHub-hosted runner and held the dump on the runner for the duration of the job before uploading it to Cloudflare R2 (row above). Job disabled 2026-09-11; no backup artifacts are retained on GitHub. (This cell said NONE until 2026-10-18, which was false while the workflow ran.) 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 days | n/a (no kid data sent by scheduled jobs) | n/a | n/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 retention | Account-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 retention | Account-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
- Cloudflare (R2) — CONFIRMED 2026-10-19 (Mike, from the primary sources). The DPA is incorporated by reference at §6.1 of Cloudflare's Self-Serve Subscription Agreement (
cloudflare.com/terms/), which is accepted by using the Services; the operative document is the Customer DPA v6.4, effective 2026-04-03 (cloudflare.com/cloudflare-customer-dpa/), covering the whole 2026-07-27..2026-09-11 backup window. Cloudflare's GDPR FAQ (cloudflare.com/trust-hub/gdpr/) confirms both the incorporation and that no action is required. There is no Cloudflare DPA inbusiness/legal/and there should not be one: incorporation by reference means there is nothing to countersign.
The five items still unchecked 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:
- DB cascade —
kid_accounts,student_tokens,parent_link_requests, gameplay tables (xp, packs, etc.) all cascade-delete via FK ON DELETE CASCADE. SLA: synchronous. - Sentry — purge events tagged with
studentIdvia the issues API. SLA: queued + completes within 30 days. - Resend — purge logs by
recipient_hash(sha256 of normalized email). SLA: queued + completes within 30 days. - Vercel logs — file a support ticket. Manual SLA: 30 days, documented in privacy notice.
- GitHub Actions — transient processing during the backup job
2026-07-27..2026-09-11; job disabled 2026-09-11; no artifacts retained on
GitHub. Nothing to purge there. (Said "n/a (no artifacts retained per R0.1)"
until 2026-10-18, which ignored that the runner held the dump during the job.)
5a. Cloudflare R2 — still NOT reached by this fan-out, and the bucket
is empty, which are two different facts. The nightly dump files written
2026-07-27..2026-09-11 were deleted by hand on 2026-10-19 (Mike,
Cloudflare dashboard; Mike-reported — see the row-provenance note on the
Cloudflare R2 row above, and note that nothing in this repo read the
bucket to confirm it). So there is nothing here for a deletion request to
reach today. The fan-out gap itself is unchanged: no adapter in
lib/coppa/data-deletion.tstouches R2 (runDeletionFanOutcalls six adapters: sentry, resend, vercel_logs, github_artifacts, upstash, neon_backups), so the moment the backup job is re-enabled a deletion honoured in the database is not applied to the new dump files. Such an object would be removed two other ways. (a) Expiry: the bucket's lifecycle rule, "delete uploaded objects after 30 days", set 2026-10-19 and still in force, removes each object 30 days after its upload date; R2 sweeps asynchronously, so an object past 30 days is due for deletion, not proven gone. (b) Manual: deletion via the Cloudflare dashboard or the S3-compatible API, on request. Disclosed on the privacy page ("Third-Party Services (Sub-Processors)", final paragraph). - 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-analyticsnever callsexpire/pexpire, so the usage record — which contains raw client IPs — accumulated with no TTL.analytics: falseshipped 2026-08-08 (grepanalytics:inlib/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. SeeWQ-UPSTASH-ANALYTICS-RETAINS-RAW-CHILD-IPS(RESOLVED 2026-08-27: 401 keys purged). 2026-10-16 23:37 EDT — the orphaned STEAM-night raffle listraffle:steam-night:2026-04-23(parent names/emails, written by a route deleted in240ec410; seeWQ-UPSTASH-RAFFLE-KEY-HOLDS-PARENT-PII-WITH-NO-CODE-PATH-LEFT) was deleted from the production instance on Mike's approval:DELreturned 1,EXISTSafter returned 0,SCAN MATCH *after returned an empty cursor,DBSIZE1 → 0. As of that moment this processor holds no keys at all; counter keys reappear only as TTL-bounded digests during traffic. - Stripe — kids never have Stripe records; teacher record stays per teacher's own deletion request.
- Neon — point-in-time backup retention is 7 days; the deletion propagates to backup snapshots within that window.
- 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.mdif SLA shifts. - A future COPPA Rule amendment from FTC → audit the entire matrix for conformance.
- Annual review at the launch anniversary (calendar event recommended).