Merge pull request 'fix(legal): declare what the app sends as collected, not shared (D84)' (#44) from fix/play-data-safety-collected into main
Some checks failed
PR checks / checks (push) Failing after -1m24s
Build and publish the image / build (push) Successful in 3m5s
Build and publish the image / deploy (push) Successful in 30s

Reviewed-on: #44
This commit is contained in:
2026-10-29 22:30:29 +00:00
6 changed files with 606 additions and 139 deletions

20
PLAN.md
View File

@@ -237,7 +237,7 @@ Taken by the org lead (Colby Whitlock) on 2026-08-19. Recorded so they are not r
**Decisions after D13 are recorded where they were taken**, in the section describing the phase that **Decisions after D13 are recorded where they were taken**, in the section describing the phase that
raised them, rather than appended here — a decision is only re-litigated when its reasoning is raised them, rather than appended here — a decision is only re-litigated when its reasoning is
somewhere other than the thing it decided. The count of record is **eighty-three**: somewhere other than the thing it decided. The count of record is **eighty-four**:
| # | Where | What it settled | | # | Where | What it settled |
|---|---|---| |---|---|---|
@@ -263,6 +263,7 @@ somewhere other than the thing it decided. The count of record is **eighty-three
| D75–D79 | §16, "How phase 17 built it" | Core marketing pages describe what a game module adds in core's words and link to each game's page; the homepage keeps its game screenshot, labelled; `/app/` describes the Rust screens, released the same day; `/runicnpc/` is in the header; `checkTerms.mjs` gains a pass over the built pages | | D75–D79 | §16, "How phase 17 built it" | Core marketing pages describe what a game module adds in core's words and link to each game's page; the homepage keeps its game screenshot, labelled; `/app/` describes the Rust screens, released the same day; `/runicnpc/` is in the header; `checkTerms.mjs` gains a pass over the built pages |
| D80–D81 | §16, "How phase 18 built it" | A name in a screenshot is a role; the Rust captures' players are mock data | | D80–D81 | §16, "How phase 18 built it" | A name in a screenshot is a role; the Rust captures' players are mock data |
| D82–D83 | §15, "How the site reaches it" | `/beta` lists the demo's sign-ins as the demo publishes them; a demo deep link names its game and shows only for the games the mounted `demoGames` lists | | D82–D83 | §15, "How the site reaches it" | `/beta` lists the demo's sign-ins as the demo publishes them; a demo deep link names its game and shows only for the games the mounted `demoGames` lists |
| D84 | §9, "How phase 6 built the legal pages" | The Data Safety answers say "collected, not shared" for everything the app sends, amending D33 |
--- ---
@@ -1068,6 +1069,23 @@ not shared"**, failing with the reason rather than a diff: "we operate no server
is the premise of the whole section, and a telemetry endpoint added later must not be able to is the premise of the whole section, and a telemetry endpoint added later must not be able to
produce a row that quietly contradicts the lede three inches above it. produce a row that quietly contradicts the lede three inches above it.
**D84 — The Data Safety answers say "collected" for everything the app sends** (org lead,
2026-10-29, Android `PLAN.md` M8; amends D33). Play counts data that leaves the device as
collected, whoever runs the server it lands on, so "not collected because we run no server" was the
wrong answer to the form's question even though it is the right sentence for `/privacy`. An audit of
the app's `edge` (every Retrofit body, query and header in `data/api/`) found more leaving the phone
than section 2 listed: the sign-in itself, the device's make and model on a trusted-device request,
the User-Agent, game-account links and in-app game-account creation, the Contact form's name, email
and message, staff posts and moderation, notification read state and preferences, and search text.
Each is now a row, declared **collected, not shared, encrypted in transit, required**; the rows
that stay on the phone say so and carry no console type. `PlayMapping` takes a list of `types`
(checked against `PLAY_DATA_TYPES`, the console's own vocabulary, which caught the original file
filing "Other user-generated content" under Messages), `required` and `purposes`; the generator
folds rows into one answer per data type, lists every type that is not collected, and answers the
form's security questions. D33's test now asserts the part of the premise that is still true:
**nothing is shared**. `/privacy` section 2 keeps saying none of it reaches us, and `lastUpdated`
moved with it. Report, block and hide (Android M8 step 3) adds its rows when it lands.
**What phase 6 also closed.** `/privacy/` and `/terms/` were the last two entries in **What phase 6 also closed.** `/privacy/` and `/terms/` were the last two entries in
`checkLinks.mjs`'s `PLANNED_ROUTES`; building them emptied the list, and its reverse check is what `checkLinks.mjs`'s `PLANNED_ROUTES`; building them emptied the list, and its reverse check is what
forced the deletion. The list itself stays, because §10's documentation routes land in phases 7 and 8 forced the deletion. The list itself stays, because §10's documentation routes land in phases 7 and 8

View File

@@ -19,49 +19,99 @@ The Play Console asks, for every category of data, whether the app **collects**
We operate **no server the app talks to.** The app ships pointed at nothing: its first screen asks for the address of a Runic Gateway deployment and validates it before anything else in the app runs. That deployment belongs to whoever runs that community. Data therefore travels from the device to *their* server, and there is no endpoint of ours anywhere in the path — not for content, not for telemetry, and not for crash reports, of which there are none. We operate **no server the app talks to.** The app ships pointed at nothing: its first screen asks for the address of a Runic Gateway deployment and validates it before anything else in the app runs. That deployment belongs to whoever runs that community. Data therefore travels from the device to *their* server, and there is no endpoint of ours anywhere in the path — not for content, not for telemetry, and not for crash reports, of which there are none.
That is why nearly every answer below is "not collected", and it is also the answer most likely to be questioned in a review. The supporting facts are in the table: each row names the file it was read out of. Play still counts what the app sends to that server as **collected**: the data leaves the device, and the form does not ask whose server it lands on. So everything the app sends is declared collected, not shared, and required, which is the conservative answer and the accurate one (org lead, 2026-10-29). What stays true, and what `/privacy` says, is that none of it reaches us.
Where the console offers free text about security practices, two things are worth saying: credentials are held in Android’s encrypted storage (AES-256-GCM via Jetpack Security), and push notifications carry **no content** — a relay receives a stream name and a reference, and the app fetches the actual message over its own authenticated connection. ## Data collection and security
## Data types - **Does the app collect or share any of the required user data types?** Yes, it collects them (below). It shares none.
- **Is all of the user data collected by the app encrypted in transit?** Yes. The release build’s network security config sets `cleartextTrafficPermitted="false"` for every connection, and the app refuses an `http` deployment address outside debug builds. Read from: `app/src/main/res/xml/network_security_config.xml`, `core/net/ServerUrl.kt`.
- **Is any of it processed ephemerally?** No. The deployment keeps what it receives, under its operator’s policy, and we cannot promise otherwise on its behalf.
- **Free text on security practices**, where the console offers it: credentials are held in Android’s encrypted storage (AES-256-GCM via Jetpack Security), and push notifications carry **no content**: a relay receives a stream name and a reference, and the app fetches the actual message over its own authenticated connection.
| Category | Data type | Collected by us | Shared by us | Answer | ## Data types the app collects
|---|---|---|---|---|
| Personal info | User IDs | No | No | Not collected by us. | | Category | Data type | Collected | Shared | Required | Purposes | From |
| Personal info | User IDs | No | No | Not collected by us. | |---|---|---|---|---|---|---|
| App info and performance | Other app data | No | No | Not collected by us. Stored on the device only. | | Personal info | Name | Yes | No | Yes | App functionality | Your name, email address and message, if you use Contact |
| Messages | Other in-app messages | No | No | Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. | | Personal info | Email address | Yes | No | Yes | App functionality | Your name, email address and message, if you use Contact |
| Messages | Other user-generated content | No | No | Not collected by us. | | Personal info | User IDs | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance; Account management | Your sign-in, and the tokens it issues; Linking a game account |
| Messages | Other in-app messages | No | No | Not collected by us. Stored on the device only. | | Messages | Other in-app messages | Yes | No | Yes | App functionality | Your name, email address and message, if you use Contact; What you post and send from the app |
| Device or other IDs | Device or other IDs | No | No | Not collected. | | App activity | App interactions | Yes | No | Yes | App functionality; Personalization | Which notifications you have read, and which you want |
| App activity | In-app search history | Yes | No | Yes | App functionality | What you search for |
| App activity | Other user-generated content | Yes | No | Yes | App functionality | What you post and send from the app |
| App info and performance | Diagnostics | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance | The app’s version and your Android version |
| Device or other IDs | Device or other IDs | Yes | No | Yes | App functionality; Fraud prevention, security, and compliance; Account management | The trusted-device token and the device’s name, if you asked for one; Push registration, if you turn notifications on |
## Data types the app does not collect
The form lists every type, so every one is answered. Vocabulary read from Play Console Help on 2026-10-29; check it against the console’s current list.
- **Location:** none collected.
- **Personal info:** Address, Phone number, Race and ethnicity, Political or religious beliefs, Sexual orientation, Other info: not collected.
- **Financial info:** none collected.
- **Health and fitness:** none collected.
- **Messages:** Emails, SMS or MMS: not collected.
- **Photos and videos:** none collected.
- **Audio files:** none collected.
- **Files and docs:** none collected.
- **Calendar:** none collected.
- **Contacts:** none collected.
- **App activity:** Installed apps, Other actions: not collected.
- **Web browsing:** none collected.
- **App info and performance:** Crash logs, Other app performance data: not collected.
## Held on the device only
Not collected, because none of it leaves the phone; listed so nobody declares it by mistake.
- **The address of the deployment you chose.** It never leaves the phone. It is the destination of requests, not the contents of one.
- **A snapshot of your notifications, so the inbox opens without a signal.** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere.
- **No analytics, no crash reporting, no advertising.** No crash reporter, no advertising ID, no analytics identifier, and no library that would generate one is linked into the build.
## Each answer, and why it is the truthful one ## Each answer, and why it is the truthful one
### Your sign-in tokens ### Your sign-in, and the tokens it issues
**Personal info → User IDs.** Not collected by us. **Personal info → User IDs.** Collected, not shared, required.
When you sign in to a deployment, the app keeps the access and refresh tokens it was issued, plus the username, role and account id they belong to. They are held in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are sent to exactly one place: the deployment that issued them. Purposes: App functionality; Account management; Fraud prevention, security, and compliance.
- **Why that answer:** The credentials are issued by, and returned to, a server the user nominated. Nothing reaches an endpoint under our control, because we run none. Signing in sends your username and password to the deployment, or with single sign-on a one-time code from the provider, plus a two-factor code when the account has one. The deployment answers with an access and a refresh token and the username, role and account id they belong to. The tokens are held in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are sent to exactly one place: the deployment that issued them, with every request. Changing your username or password, setting up two-factor sign-in and generating recovery codes from the app go to the same place.
- **Why that answer:** The username, password and tokens leave the phone for the deployment the user chose, and Play counts that as collection whoever runs the server. None of it reaches us. Security is a purpose because the deployment checks two-factor codes and trusted devices with it.
- **Retention:** On the device until you sign out - **Retention:** On the device until you sign out
- **In detail:** Signing out clears them; uninstalling the app removes them with it. - **In detail:** Signing out clears them; uninstalling the app removes them with it.
- **Read from:** `core/auth/EncryptedTokenStore.kt` - **Read from:** `data/api/AuthApi.kt, data/api/SsoApi.kt, data/api/MeApi.kt, core/auth/EncryptedTokenStore.kt`
### The trusted-device token, if you asked for one ### The trusted-device token and the device’s name, if you asked for one
**Personal info → User IDs.** Not collected by us. **Device or other IDs → Device or other IDs.** Collected, not shared, required.
Ticking “trust this device” during two-factor sign-in stores an opaque token so the deployment can skip the second factor next time. It lives in its own encrypted store, deliberately separate from the session, because it has to outlive a sign-out to be worth anything — and the deployment holds only a hash of it, so the copy on your phone is the only usable one. Purposes: Account management; Fraud prevention, security, and compliance.
- **Why that answer:** Same as the session tokens: minted by the user’s deployment, stored on the device, presented back to that same deployment. Ticking “trust this device” during two-factor sign-in stores an opaque token so the deployment can skip the second factor next time. It lives in its own encrypted store, deliberately separate from the session, because it has to outlive a sign-out to be worth anything — and the deployment holds only a hash of it, so the copy on your phone is the only usable one. The phone’s make and model, such as “Samsung SM-S918B”, goes with the request as the device’s label, so you can tell it apart in the deployment’s list of trusted devices. It is a model name, not a hardware identifier.
- **Why that answer:** The token identifies this device to the deployment, and the label is sent with it. Both are presented to the deployment that minted the token and to nothing else.
- **Retention:** On the device until it expires or you revoke it - **Retention:** On the device until it expires or you revoke it
- **In detail:** Thirty days, and revocable at any time from the deployment’s Trusted Devices screen, which is also where it can be revoked if the phone is lost. - **In detail:** Thirty days, and revocable at any time from the deployment’s Trusted Devices screen, which is also where it can be revoked if the phone is lost.
- **Read from:** `core/auth/EncryptedTrustTokenStore.kt` - **Read from:** `core/auth/EncryptedTrustTokenStore.kt, core/auth/DeviceNameProvider.kt`
### The app’s version and your Android version
**App info and performance → Diagnostics.** Collected, not shared, required.
Purposes: App functionality; Fraud prevention, security, and compliance.
Every request carries a User-Agent such as “RunicGatewayApp/0.6.0 (Android 15)”. It is there because a deployment turns away traffic that looks like a scanner, and it names nothing about you or the phone beyond those two version numbers.
- **Why that answer:** It is technical information about the app and the device, and it is sent with every request. The deployment uses it to tell the app from a scanner.
- **Retention:** Under the deployment operator’s policy
- **In detail:** A deployment’s access log may keep it, as any web server keeps the user agent of a browser.
- **Read from:** `di/NetworkModule.kt, core/net/UserAgentInterceptor.kt`
### The address of the deployment you chose ### The address of the deployment you chose
**App info and performance → Other app data.** Not collected by us. Stored on the device only. **No console type.** Not collected. Stored on the device only.
The app ships pointed at nothing and asks for an address on first run. That address is stored in ordinary preferences rather than encrypted storage — it is not a secret, it is the equivalent of a bookmark — and it is what every other screen in the app talks to. The app ships pointed at nothing and asks for an address on first run. That address is stored in ordinary preferences rather than encrypted storage — it is not a secret, it is the equivalent of a bookmark — and it is what every other screen in the app talks to.
@@ -71,43 +121,97 @@ The app ships pointed at nothing and asks for an address on first run. That addr
### Push registration, if you turn notifications on ### Push registration, if you turn notifications on
**Messages → Other in-app messages.** Not collected by us. Declare the relay hop in the console’s free-text security section if it asks. **Device or other IDs → Device or other IDs.** Collected, not shared, required. Mention the relay in the free-text security section if the console asks.
Purposes: App functionality.
Push is off until you enable it. When you do, the app mints a random, unguessable topic name on the notification relay the deployment nominates, and registers that topic’s URL with the deployment so it has somewhere to send a nudge. What actually travels through the relay is content-free — a stream name and a reference, never the message — and the app then fetches the real content over its authenticated connection to the deployment. A leaked topic name therefore reveals nothing, which is the reason the relay needs no account and holds nothing about you. Push is off until you enable it. When you do, the app mints a random, unguessable topic name on the notification relay the deployment nominates, and registers that topic’s URL with the deployment so it has somewhere to send a nudge. What actually travels through the relay is content-free — a stream name and a reference, never the message — and the app then fetches the real content over its authenticated connection to the deployment. A leaked topic name therefore reveals nothing, which is the reason the relay needs no account and holds nothing about you.
- **Why that answer:** The notification passes through a relay chosen by the deployment, and it carries no content — the app pulls the content itself, authenticated. Neither hop reaches a server we operate. - **Why that answer:** The topic URL is an address for this device, and the app sends it to the deployment. The relay is the one the deployment nominates to deliver its own notifications, so it carries them on the deployment’s behalf rather than receiving a share. It only ever sees a topic name and a tickle; the app pulls the content itself, authenticated. No hop reaches a server we operate.
- **Retention:** Until you turn push off, sign out, or uninstall - **Retention:** Until you turn push off, sign out, or uninstall
- **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic. The relay retains whatever its own operator configures it to; if the deployment points at a relay it does not run, that relay is a third party to both of us, and it still only ever sees a tickle. - **In detail:** Signing out or disabling push unregisters the device with the deployment and discards the topic. The relay retains whatever its own operator configures it to; if the deployment points at a relay it does not run, that relay is a third party to both of us, and it still only ever sees a tickle.
- **Read from:** `core/push/NtfyTopic.kt, core/push/PushPreferences.kt` - **Read from:** `core/push/NtfyTopic.kt, core/push/PushPreferences.kt, data/api/NotificationsApi.kt`
### Everything you read and post in the app ### Linking a game account
**Messages → Other user-generated content.** Not collected by us. **Personal info → User IDs.** Collected, not shared, required.
Forum posts, Team activity, what the game module shows you, notification preferences: all of it is a live read or write against the deployment. Apart from the notification snapshot described in the next entry, nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with almost no content, which is a limitation and also an accurate description of where the data lives. Purposes: App functionality; Account management.
- **Why that answer:** Content is written to the community’s own installation. We have no copy, no access and no way to obtain one. Linking your site account to a game sends the one-time code the game gave you. Where a game server offers it, the app can also create a new game account, which sends the account name and password you chose. Unlinking sends the id being unlinked, such as a Steam id. All of it goes to the deployment, which keeps the link against your site account.
- **Why that answer:** Game account names, Steam ids and link codes identify a player, and the app sends them to the deployment.
- **Retention:** Held by the deployment until you unlink, under its operator’s policy
- **Read from:** `data/api/PlayerShardApi.kt, data/api/PlayerRustApi.kt, data/api/MeApi.kt`
### Your name, email address and message, if you use Contact
**Personal info → Name; Personal info → Email address; Messages → Other in-app messages.** Collected, not shared, required.
Purposes: App functionality.
The Contact screen sends the name, email address and message you type to the deployment, which passes them on to its operator by email so they can reply. Nothing is sent until you press send.
- **Why that answer:** The form’s three fields leave the phone for the deployment, whose operator receives them.
- **Retention:** Held by the deployment’s operator, under their policy
- **In detail:** The message is delivered to the operator’s mailbox, and kept as long as they keep it.
- **Read from:** `data/api/PublicApi.kt, data/api/dto/ContactDto.kt`
### What you post and send from the app
**Messages → Other in-app messages; App activity → Other user-generated content.** Collected, not shared, required.
Purposes: App functionality.
Most of the app is reading. What it sends is what you write, and most of that is for staff: news posts, wiki categories, a broadcast to the game, the answer to a player’s help page, and the account and reason on a kick or a ban. All of it is a live write to the deployment. Apart from the notification snapshot described below, nothing is cached for offline use and nothing is duplicated anywhere else — the app with no signal is an app with almost no content, which is a limitation and also an accurate description of where the data lives.
- **Why that answer:** Posts, broadcasts and replies are written to the community’s own installation. We have no copy, no access and no way to obtain one.
- **Retention:** Held by the deployment, under its operator’s policy - **Retention:** Held by the deployment, under its operator’s policy
- **Read from:** `PLAN.md §9 section 2` - **Read from:** `data/api/AdminApi.kt`
### Which notifications you have read, and which you want
**App activity → App interactions.** Collected, not shared, required.
Purposes: App functionality; Personalization.
Opening a notification marks it read on the deployment, so the badge agrees on every device you use. Your choice of notification streams, and of which arrive by push or email, is saved there too.
- **Why that answer:** Read state and preferences are actions in the app that are sent to, and kept by, the deployment.
- **Retention:** Held by the deployment, under its operator’s policy
- **Read from:** `data/api/NotificationsApi.kt`
### What you search for
**App activity → In-app search history.** Collected, not shared, required.
Purposes: App functionality.
Searching the wiki, a game’s market or its creature atlas sends what you typed to the deployment, because the deployment does the searching. The app keeps no history of searches.
- **Why that answer:** Search text leaves the phone in the request to the deployment.
- **Retention:** Under the deployment operator’s policy
- **In detail:** The query is part of the request’s address, so a deployment’s access log may record it. That is why it is declared rather than called ephemeral.
- **Read from:** `data/api/PublicApi.kt`
### A snapshot of your notifications, so the inbox opens without a signal ### A snapshot of your notifications, so the inbox opens without a signal
**Messages → Other in-app messages.** Not collected by us. Stored on the device only. **No console type.** Not collected. Stored on the device only.
The app keeps the most recent notifications it has already fetched — at most thirty, and only the first page — on the device, so opening the inbox shows you what you had rather than a spinner. It is a copy of what the deployment already sent you and it is refreshed from there; nothing is written here that was not read from your own account. It is scoped to the account that fetched it, so a second person signing in on the same phone is never shown the first one’s messages. The app keeps the most recent notifications it has already fetched — at most thirty, and only the first page — on the device, so opening the inbox shows you what you had rather than a spinner. It is a copy of what the deployment already sent you and it is refreshed from there; nothing is written here that was not read from your own account. It is scoped to the account that fetched it, so a second person signing in on the same phone is never shown the first one’s messages.
- **Why that answer:** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere, and no server we operate is on either end of it. - **Why that answer:** The snapshot is written on the phone from data the deployment had already delivered. It is not uploaded anywhere.
- **Retention:** Until you sign out, or the thirty are pushed out by newer ones - **Retention:** Until you sign out, or the thirty are pushed out by newer ones
- **In detail:** Signing out deletes the snapshot outright. It lives in the app’s ordinary preference store rather than the encrypted one — sign-in tokens are the thing that store is for — which is worth stating plainly: on a device where someone has root, these are readable, and they are notification bodies rather than credentials. - **In detail:** Signing out deletes the snapshot outright. It lives in the app’s ordinary preference store rather than the encrypted one — sign-in tokens are the thing that store is for — which is worth stating plainly: on a device where someone has root, these are readable, and they are notification bodies rather than credentials.
- **Read from:** `core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt` - **Read from:** `core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt`
### No analytics, no crash reporting, no advertising ### No analytics, no crash reporting, no advertising
**Device or other IDs → Device or other IDs.** Not collected. **App info and performance → Crash logs.** Not collected. The advertising ID declaration is "no".
There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no advertising identifier, no measurement library. That is checkable rather than claimed: it is what the dependency list and the manifest say, and a build that gained one would gain permissions with it. There is no third-party SDK in the app at all — no Firebase, no Crashlytics, no advertising identifier, no measurement library. That is checkable rather than claimed: it is what the dependency list and the manifest say, and a build that gained one would gain permissions with it.
- **Why that answer:** No advertising ID, no analytics identifier, and no library that would generate one is linked into the build. - **Why that answer:** No crash reporter, no advertising ID, no analytics identifier, and no library that would generate one is linked into the build.
- **Retention:** Nothing to retain - **Retention:** Nothing to retain
- **Read from:** `app/build.gradle.kts, app/src/main/AndroidManifest.xml` - **Read from:** `app/build.gradle.kts, app/src/main/AndroidManifest.xml`
@@ -116,6 +220,7 @@ There is no third-party SDK in the app at all — no Firebase, no Crashlytics, n
- **Privacy policy URL:** `/privacy` on this site. It is the URL Play is given, and section 2 of it is about the app specifically. - **Privacy policy URL:** `/privacy` on this site. It is the URL Play is given, and section 2 of it is about the app specifically.
- **Target audience:** adults. The beta is stated as **18 or older** (D31); the app contains no content directed at children and no age verification. - **Target audience:** adults. The beta is stated as **18 or older** (D31); the app contains no content directed at children and no age verification.
- **Account deletion:** the app creates no account with us — an account belongs to the deployment the user chose, and is deleted there. The only list we hold is the beta signup, which is erased on request; `/privacy` section 4 says how to ask. - **Account deletion:** the app creates no account with us — an account belongs to the deployment the user chose, and is deleted there. The only list we hold is the beta signup, which is erased on request; `/privacy` section 4 says how to ask.
- **Accounts the app can start:** the sign-in screen links to the deployment’s own sign-up page, which opens in the browser, and where a game server offers it the app creates a game account on it. The platform has no self-service account deletion today; a deployment’s admin deletes accounts from its admin panel. Read from: `ui/auth/LoginViewModel.kt`, `data/api/PlayerShardApi.kt`.
- **Data deletion request URL:** the contact address published on `/privacy`, which is read from the mounted `brand.json` rather than typed anywhere in the source (D13). - **Data deletion request URL:** the contact address published on `/privacy`, which is read from the mounted `brand.json` rather than typed anywhere in the source (D13).
## What the website collects, for the same reviewer ## What the website collects, for the same reviewer
@@ -128,4 +233,4 @@ Not part of the Data Safety form — that form is about the app — but a review
- **Your browser’s user-agent string, truncated** — With the row; blanked on removal. - **Your browser’s user-agent string, truncated** — With the row; blanked on removal.
- **The web server’s access log** — Short-term operational retention, then rotated away. - **The web server’s access log** — Short-term operational retention, then rotated away.
Last generated from data dated 2026-09-01. Regenerate with `npm run play:datasafety` after any change to what the app stores. Last generated from data dated 2026-10-29. Regenerate with `npm run play:datasafety` after any change to what the app stores.

View File

@@ -38,7 +38,13 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import path from 'node:path'; import path from 'node:path';
import { collectedIn, playRows } from '../src/data/collection.mjs'; import {
collectedIn,
PLAY_DATA_TYPES,
PLAY_PURPOSES,
PLAY_VOCABULARY_READ,
playRows,
} from '../src/data/collection.mjs';
import { legal } from '../src/data/legal.mjs'; import { legal } from '../src/data/legal.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url)); const ROOT = fileURLToPath(new URL('..', import.meta.url));
@@ -52,6 +58,33 @@ const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s*\n\s*/g, '
const yesNo = (value) => (value ? 'Yes' : 'No'); const yesNo = (value) => (value ? 'Yes' : 'No');
/**
* The form asks per data type, not per row, and two rows can answer the same type (the
* trusted-device token and the push topic are both "Device or other IDs"). So the
* collected rows are folded into one entry per type, in the console's order: required if
* any row requires it, the union of the purposes, and the rows it came from.
*/
function declaredTypes(rows) {
const entries = [];
for (const [category, types] of Object.entries(PLAY_DATA_TYPES)) {
for (const type of types) {
const from = rows.filter(
(row) => row.play.collected && row.play.types.some((t) => t.category === category && t.type === type)
);
if (!from.length) continue;
const purposes = new Set(from.flatMap((row) => row.play.purposes));
entries.push({
category,
type,
required: from.some((row) => row.play.required),
purposes: PLAY_PURPOSES.filter((p) => purposes.has(p)),
rows: from,
});
}
}
return entries;
}
function render() { function render() {
const rows = playRows(); const rows = playRows();
const site = collectedIn('site'); const site = collectedIn('site');
@@ -101,40 +134,87 @@ function render() {
); );
lines.push(''); lines.push('');
lines.push( lines.push(
'That is why nearly every answer below is "not collected", and it is also the answer ' + 'Play still counts what the app sends to that server as **collected**: the data leaves ' +
'most likely to be questioned in a review. The supporting facts are in the table: ' + 'the device, and the form does not ask whose server it lands on. So everything the ' +
'each row names the file it was read out of.' 'app sends is declared collected, not shared, and required, which is the conservative ' +
); 'answer and the accurate one (org lead, 2026-10-29). What stays true, and what ' +
lines.push(''); '`/privacy` says, is that none of it reaches us.'
lines.push(
'Where the console offers free text about security practices, two things are worth ' +
'saying: credentials are held in Android’s encrypted storage (AES-256-GCM via ' +
'Jetpack Security), and push notifications carry **no content** — a relay receives a ' +
'stream name and a reference, and the app fetches the actual message over its own ' +
'authenticated connection.'
); );
lines.push(''); lines.push('');
/* --------------------------------------------------------------------------------- */ /* --------------------------------------------------------------------------------- */
lines.push('## Data types'); lines.push('## Data collection and security');
lines.push(''); lines.push('');
lines.push('| Category | Data type | Collected by us | Shared by us | Answer |'); lines.push('- **Does the app collect or share any of the required user data types?** Yes, it collects them (below). It shares none.');
lines.push('|---|---|---|---|---|'); lines.push(
for (const row of rows) { '- **Is all of the user data collected by the app encrypted in transit?** Yes. The ' +
'release build’s network security config sets `cleartextTrafficPermitted="false"` ' +
'for every connection, and the app refuses an `http` deployment address outside ' +
'debug builds. Read from: `app/src/main/res/xml/network_security_config.xml`, ' +
'`core/net/ServerUrl.kt`.'
);
lines.push(
'- **Is any of it processed ephemerally?** No. The deployment keeps what it receives, ' +
'under its operator’s policy, and we cannot promise otherwise on its behalf.'
);
lines.push(
'- **Free text on security practices**, where the console offers it: credentials are ' +
'held in Android’s encrypted storage (AES-256-GCM via Jetpack Security), and push ' +
'notifications carry **no content**: a relay receives a stream name and a reference, ' +
'and the app fetches the actual message over its own authenticated connection.'
);
lines.push('');
/* --------------------------------------------------------------------------------- */
const declared = declaredTypes(rows);
lines.push('## Data types the app collects');
lines.push('');
lines.push('| Category | Data type | Collected | Shared | Required | Purposes | From |');
lines.push('|---|---|---|---|---|---|---|');
for (const entry of declared) {
lines.push( lines.push(
`| ${cell(row.play.category)} | ${cell(row.play.type)} | ${yesNo(row.play.collected)} ` + `| ${cell(entry.category)} | ${cell(entry.type)} | Yes | No | ${yesNo(entry.required)} ` +
`| ${yesNo(row.play.shared)} | ${cell(row.play.answer)} |` `| ${cell(entry.purposes.join('; '))} | ${cell(entry.rows.map((r) => r.title).join('; '))} |`
); );
} }
lines.push(''); lines.push('');
lines.push('## Data types the app does not collect');
lines.push('');
lines.push(
'The form lists every type, so every one is answered. Vocabulary read from Play ' +
`Console Help on ${PLAY_VOCABULARY_READ}; check it against the console’s current list.`
);
lines.push('');
for (const [category, types] of Object.entries(PLAY_DATA_TYPES)) {
const rest = types.filter((type) => !declared.some((e) => e.category === category && e.type === type));
if (!rest.length) continue;
const all = rest.length === types.length;
lines.push(`- **${category}:** ${all ? 'none collected' : `${rest.join(', ')}: not collected`}.`);
}
lines.push('');
const onDevice = rows.filter((row) => !row.play.collected);
lines.push('## Held on the device only');
lines.push('');
lines.push('Not collected, because none of it leaves the phone; listed so nobody declares it by mistake.');
lines.push('');
for (const row of onDevice) lines.push(`- **${cell(row.title)}.** ${cell(row.play.because)}`);
lines.push('');
lines.push('## Each answer, and why it is the truthful one'); lines.push('## Each answer, and why it is the truthful one');
lines.push(''); lines.push('');
for (const row of rows) { for (const row of rows) {
const types = row.play.types.map((t) => `${t.category} → ${t.type}`).join('; ');
lines.push(`### ${row.title}`); lines.push(`### ${row.title}`);
lines.push(''); lines.push('');
lines.push(`**${row.play.category} → ${row.play.type}.** ${cell(row.play.answer)}`); lines.push(`**${types || 'No console type'}.** ${cell(row.play.answer)}`);
lines.push(''); lines.push('');
if (row.play.collected) {
lines.push(`Purposes: ${row.play.purposes.join('; ')}.`);
lines.push('');
}
lines.push(cell(row.body)); lines.push(cell(row.body));
lines.push(''); lines.push('');
lines.push(`- **Why that answer:** ${cell(row.play.because)}`); lines.push(`- **Why that answer:** ${cell(row.play.because)}`);
@@ -160,6 +240,13 @@ function render() {
'the deployment the user chose, and is deleted there. The only list we hold is the ' + 'the deployment the user chose, and is deleted there. The only list we hold is the ' +
'beta signup, which is erased on request; `/privacy` section 4 says how to ask.' 'beta signup, which is erased on request; `/privacy` section 4 says how to ask.'
); );
lines.push(
'- **Accounts the app can start:** the sign-in screen links to the deployment’s own ' +
'sign-up page, which opens in the browser, and where a game server offers it the app ' +
'creates a game account on it. The platform has no self-service account ' +
'deletion today; a deployment’s admin deletes accounts from its admin panel. Read ' +
'from: `ui/auth/LoginViewModel.kt`, `data/api/PlayerShardApi.kt`.'
);
lines.push( lines.push(
'- **Data deletion request URL:** the contact address published on `/privacy`, which ' + '- **Data deletion request URL:** the contact address published on `/privacy`, which ' +
'is read from the mounted `brand.json` rather than typed anywhere in the source (D13).' 'is read from the mounted `brand.json` rather than typed anywhere in the source (D13).'

View File

@@ -24,8 +24,9 @@
* `site` This website. We are the data controller. It is one form. * `site` This website. We are the data controller. It is one form.
* `app` The Android app. We operate NO server it talks to — every byte goes to * `app` The Android app. We operate NO server it talks to — every byte goes to
* a deployment the user typed the address of, run by whoever runs that * a deployment the user typed the address of, run by whoever runs that
* community. What is listed here is therefore mostly what the device * community. What is listed here is what the device holds and what it
* HOLDS, not what we receive, because we receive nothing. * sends to that deployment. None of it reaches us, because we receive
* nothing; Play still counts what is sent as collected (see `PlayMapping`).
* `deployment` A self-hosted install of the platform. The operator is the controller, * `deployment` A self-hosted install of the platform. The operator is the controller,
* not us. This scope exists so an operator sees the responsibility they * not us. This scope exists so an operator sees the responsibility they
* are taking on, and so a player never mistakes this policy for the one * are taking on, and so a player never mistakes this policy for the one
@@ -46,13 +47,23 @@
* @property {string} summary Short enough to sit in a table cell. * @property {string} summary Short enough to sit in a table cell.
* @property {string} [detail] The mechanism, where the summary alone would be a promise. * @property {string} [detail] The mechanism, where the summary alone would be a promise.
* *
* @typedef {object} PlayType
* @property {string} category The console's grouping, a key of `PLAY_DATA_TYPES`.
* @property {string} type The console's data type within that grouping.
*
* @typedef {object} PlayMapping * @typedef {object} PlayMapping
* How this row answers Google Play's Data Safety form. `app` scope only — see * How this row answers Google Play's Data Safety form. `app` scope only — see
* `scripts/playDataSafety.mjs`, which is the only reader. * `scripts/playDataSafety.mjs`, which is the only reader.
* @property {string} category The console's grouping, e.g. "Personal info". * @property {PlayType[]} types The console data types this row answers.
* @property {string} type The console's data type within that grouping. * @property {boolean} collected Does it leave the device? Play counts data the app sends
* @property {boolean} collected Does it leave the device to a server WE operate? * off the phone as collected, whoever runs the server it
* @property {boolean} shared Do we pass it to a third party? * goes to. Ours receives none of it, because we run none;
* the deployment the user chose receives it (org lead,
* 2026-10-29: the conservative answers).
* @property {boolean} shared Is it passed to a third party? Never, for this app.
* @property {boolean} [required] Collected rows only. Declared required throughout (same
* decision), rather than arguing feature by feature.
* @property {string[]} [purposes] Collected rows only, from `PLAY_PURPOSES`.
* @property {string} answer The recommended console answer, in one line. * @property {string} answer The recommended console answer, in one line.
* @property {string} because Why that answer is the truthful one. * @property {string} because Why that answer is the truthful one.
* *
@@ -64,9 +75,64 @@
* @property {Retention} retention * @property {Retention} retention
* @property {string} source The file this was read out of, repo-relative. * @property {string} source The file this was read out of, repo-relative.
* @property {PlayMapping} [play] * @property {PlayMapping} [play]
*
* @type {Collected[]}
*/ */
/**
* The Data Safety form's own vocabulary: every category and the data types under it, and
* the purposes a collected type can be given. Read from Play Console Help ("Provide
* information for Google Play's Data safety section") on `PLAY_VOCABULARY_READ`.
*
* Here so a typo or a misfiled type fails a test instead of reaching the console. The
* first version of this file filed "Other user-generated content" under Messages; it
* lives under App activity. The full list is also what lets the generator say, for every
* type the app does NOT collect, that it does not — the form asks about all of them.
*/
export const PLAY_VOCABULARY_READ = '2026-10-29';
export const PLAY_DATA_TYPES = {
Location: ['Approximate location', 'Precise location'],
'Personal info': [
'Name',
'Email address',
'User IDs',
'Address',
'Phone number',
'Race and ethnicity',
'Political or religious beliefs',
'Sexual orientation',
'Other info',
],
'Financial info': ['User payment info', 'Purchase history', 'Credit score', 'Other financial info'],
'Health and fitness': ['Health info', 'Fitness info'],
Messages: ['Emails', 'SMS or MMS', 'Other in-app messages'],
'Photos and videos': ['Photos', 'Videos'],
'Audio files': ['Voice or sound recordings', 'Music files', 'Other audio files'],
'Files and docs': ['Files and docs'],
Calendar: ['Calendar events'],
Contacts: ['Contacts'],
'App activity': [
'App interactions',
'In-app search history',
'Installed apps',
'Other user-generated content',
'Other actions',
],
'Web browsing': ['Web browsing history'],
'App info and performance': ['Crash logs', 'Diagnostics', 'Other app performance data'],
'Device or other IDs': ['Device or other IDs'],
};
export const PLAY_PURPOSES = [
'App functionality',
'Analytics',
'Developer communications',
'Advertising or marketing',
'Fraud prevention, security, and compliance',
'Personalization',
'Account management',
];
/** @type {Collected[]} */
export const collected = [ export const collected = [
/* ===================================================================================== /* =====================================================================================
1. THIS WEBSITE 1. THIS WEBSITE
@@ -163,8 +229,14 @@ export const collected = [
the app talks to. `ConnectScreen.kt` gates the entire app on an address the user the app talks to. `ConnectScreen.kt` gates the entire app on an address the user
enters and the app validates; everything below either stays on the phone or goes to enters and the app validates; everything below either stays on the phone or goes to
that address. There is no telemetry SDK, no crash reporter and no analytics in the that address. There is no telemetry SDK, no crash reporter and no analytics in the
build — the manifest asks for INTERNET, network state, notifications and a data-sync build — the manifest asks for INTERNET, network state, notifications, a special-use
foreground service, and nothing else. foreground service for push and the boot broadcast that restarts it, and nothing else.
What goes to that address is still COLLECTED in Play's sense, because it leaves the
phone, and it is declared that way (org lead, 2026-10-29). Read out of the Retrofit
interfaces in `data/api/` on the Android app's `edge` (19c2f77): every request body,
query and header the app sends is in one of the rows below. Rows that stay on the
phone say so, and carry no console type.
`play` is filled in on every row here, because a row in this scope with no mapping is `play` is filled in on every row here, because a row in this scope with no mapping is
a question on the console form that somebody will answer from memory (D33). a question on the console form that somebody will answer from memory (D33).
@@ -172,54 +244,98 @@ export const collected = [
{ {
id: 'app-session-tokens', id: 'app-session-tokens',
scope: 'app', scope: 'app',
title: 'Your sign-in tokens', title: 'Your sign-in, and the tokens it issues',
body: body:
'When you sign in to a deployment, the app keeps the access and refresh tokens it ' + 'Signing in sends your username and password to the deployment, or with single ' +
'was issued, plus the username, role and account id they belong to. They are held ' + 'sign-on a one-time code from the provider, plus a two-factor code when the account ' +
'in encrypted storage on the device (AES-256-GCM through Jetpack Security) and are ' + 'has one. The deployment answers with an access and a refresh token and the ' +
'sent to exactly one place: the deployment that issued them.', 'username, role and account id they belong to. The tokens are held in encrypted ' +
'storage on the device (AES-256-GCM through Jetpack Security) and are sent to ' +
'exactly one place: the deployment that issued them, with every request. Changing ' +
'your username or password, setting up two-factor sign-in and generating recovery ' +
'codes from the app go to the same place.',
retention: { retention: {
summary: 'On the device until you sign out', summary: 'On the device until you sign out',
detail: 'Signing out clears them; uninstalling the app removes them with it.', detail: 'Signing out clears them; uninstalling the app removes them with it.',
}, },
source: 'core/auth/EncryptedTokenStore.kt', source:
'data/api/AuthApi.kt, data/api/SsoApi.kt, data/api/MeApi.kt, core/auth/EncryptedTokenStore.kt',
play: { play: {
category: 'Personal info', types: [{ category: 'Personal info', type: 'User IDs' }],
type: 'User IDs', collected: true,
collected: false,
shared: false, shared: false,
answer: 'Not collected by us.', required: true,
purposes: [
'App functionality',
'Account management',
'Fraud prevention, security, and compliance',
],
answer: 'Collected, not shared, required.',
because: because:
'The credentials are issued by, and returned to, a server the user nominated. ' + 'The username, password and tokens leave the phone for the deployment the user ' +
'Nothing reaches an endpoint under our control, because we run none.', 'chose, and Play counts that as collection whoever runs the server. None of it ' +
'reaches us. Security is a purpose because the deployment checks two-factor codes ' +
'and trusted devices with it.',
}, },
}, },
{ {
id: 'app-trust-token', id: 'app-trust-token',
scope: 'app', scope: 'app',
title: 'The trusted-device token, if you asked for one', title: 'The trusted-device token and the device’s name, if you asked for one',
body: body:
'Ticking “trust this device” during two-factor sign-in stores an opaque token so ' + 'Ticking “trust this device” during two-factor sign-in stores an opaque token so ' +
'the deployment can skip the second factor next time. It lives in its own encrypted ' + 'the deployment can skip the second factor next time. It lives in its own encrypted ' +
'store, deliberately separate from the session, because it has to outlive a sign-out ' + 'store, deliberately separate from the session, because it has to outlive a sign-out ' +
'to be worth anything — and the deployment holds only a hash of it, so the copy on ' + 'to be worth anything — and the deployment holds only a hash of it, so the copy on ' +
'your phone is the only usable one.', 'your phone is the only usable one. The phone’s make and model, such as “Samsung ' +
'SM-S918B”, goes with the request as the device’s label, so you can tell it apart ' +
'in the deployment’s list of trusted devices. It is a model name, not a hardware ' +
'identifier.',
retention: { retention: {
summary: 'On the device until it expires or you revoke it', summary: 'On the device until it expires or you revoke it',
detail: detail:
'Thirty days, and revocable at any time from the deployment’s Trusted Devices ' + 'Thirty days, and revocable at any time from the deployment’s Trusted Devices ' +
'screen, which is also where it can be revoked if the phone is lost.', 'screen, which is also where it can be revoked if the phone is lost.',
}, },
source: 'core/auth/EncryptedTrustTokenStore.kt', source: 'core/auth/EncryptedTrustTokenStore.kt, core/auth/DeviceNameProvider.kt',
play: { play: {
category: 'Personal info', types: [{ category: 'Device or other IDs', type: 'Device or other IDs' }],
type: 'User IDs', collected: true,
collected: false,
shared: false, shared: false,
answer: 'Not collected by us.', required: true,
purposes: ['Account management', 'Fraud prevention, security, and compliance'],
answer: 'Collected, not shared, required.',
because: because:
'Same as the session tokens: minted by the user’s deployment, stored on the ' + 'The token identifies this device to the deployment, and the label is sent with ' +
'device, presented back to that same deployment.', 'it. Both are presented to the deployment that minted the token and to nothing ' +
'else.',
},
},
{
id: 'app-user-agent',
scope: 'app',
title: 'The app’s version and your Android version',
body:
'Every request carries a User-Agent such as “RunicGatewayApp/0.6.0 (Android 15)”. ' +
'It is there because a deployment turns away traffic that looks like a scanner, ' +
'and it names nothing about you or the phone beyond those two version numbers.',
retention: {
summary: 'Under the deployment operator’s policy',
detail:
'A deployment’s access log may keep it, as any web server keeps the user agent of ' +
'a browser.',
},
source: 'di/NetworkModule.kt, core/net/UserAgentInterceptor.kt',
play: {
types: [{ category: 'App info and performance', type: 'Diagnostics' }],
collected: true,
shared: false,
required: true,
purposes: ['App functionality', 'Fraud prevention, security, and compliance'],
answer: 'Collected, not shared, required.',
because:
'It is technical information about the app and the device, and it is sent with ' +
'every request. The deployment uses it to tell the app from a scanner.',
}, },
}, },
{ {
@@ -234,11 +350,10 @@ export const collected = [
retention: { summary: 'On the device until you change it or uninstall' }, retention: { summary: 'On the device until you change it or uninstall' },
source: 'core/prefs/ServerPreferences.kt', source: 'core/prefs/ServerPreferences.kt',
play: { play: {
category: 'App info and performance', types: [],
type: 'Other app data',
collected: false, collected: false,
shared: false, shared: false,
answer: 'Not collected by us. Stored on the device only.', answer: 'Not collected. Stored on the device only.',
because: because:
'It never leaves the phone. It is the destination of requests, not the contents ' + 'It never leaves the phone. It is the destination of requests, not the contents ' +
'of one.', 'of one.',
@@ -264,45 +379,157 @@ export const collected = [
'if the deployment points at a relay it does not run, that relay is a third party ' + 'if the deployment points at a relay it does not run, that relay is a third party ' +
'to both of us, and it still only ever sees a tickle.', 'to both of us, and it still only ever sees a tickle.',
}, },
source: 'core/push/NtfyTopic.kt, core/push/PushPreferences.kt', source: 'core/push/NtfyTopic.kt, core/push/PushPreferences.kt, data/api/NotificationsApi.kt',
play: { play: {
category: 'Messages', types: [{ category: 'Device or other IDs', type: 'Device or other IDs' }],
type: 'Other in-app messages', collected: true,
collected: false,
shared: false, shared: false,
required: true,
purposes: ['App functionality'],
answer: answer:
'Not collected by us. Declare the relay hop in the console’s free-text ' + 'Collected, not shared, required. Mention the relay in the free-text security ' +
'security section if it asks.', 'section if the console asks.',
because: because:
'The notification passes through a relay chosen by the deployment, and it carries ' + 'The topic URL is an address for this device, and the app sends it to the ' +
'no content — the app pulls the content itself, authenticated. Neither hop reaches ' + 'deployment. The relay is the one the deployment nominates to deliver its own ' +
'a server we operate.', 'notifications, so it carries them on the deployment’s behalf rather than ' +
'receiving a share. It only ever sees a topic name and a tickle; the app pulls the ' +
'content itself, authenticated. No hop reaches a server we operate.',
},
},
{
id: 'app-game-accounts',
scope: 'app',
title: 'Linking a game account',
body:
'Linking your site account to a game sends the one-time code the game gave you. ' +
'Where a game server offers it, the app can also create a new game account, which sends ' +
'the account name and password you chose. Unlinking sends the id being unlinked, ' +
'such as a Steam id. All of it goes to the deployment, which keeps the link against ' +
'your site account.',
retention: {
summary: 'Held by the deployment until you unlink, under its operator’s policy',
},
source: 'data/api/PlayerShardApi.kt, data/api/PlayerRustApi.kt, data/api/MeApi.kt',
play: {
types: [{ category: 'Personal info', type: 'User IDs' }],
collected: true,
shared: false,
required: true,
purposes: ['App functionality', 'Account management'],
answer: 'Collected, not shared, required.',
because:
'Game account names, Steam ids and link codes identify a player, and the app ' +
'sends them to the deployment.',
},
},
{
id: 'app-contact',
scope: 'app',
title: 'Your name, email address and message, if you use Contact',
body:
'The Contact screen sends the name, email address and message you type to the ' +
'deployment, which passes them on to its operator by email so they can reply. ' +
'Nothing is sent until you press send.',
retention: {
summary: 'Held by the deployment’s operator, under their policy',
detail: 'The message is delivered to the operator’s mailbox, and kept as long as they keep it.',
},
source: 'data/api/PublicApi.kt, data/api/dto/ContactDto.kt',
play: {
types: [
{ category: 'Personal info', type: 'Name' },
{ category: 'Personal info', type: 'Email address' },
{ category: 'Messages', type: 'Other in-app messages' },
],
collected: true,
shared: false,
required: true,
purposes: ['App functionality'],
answer: 'Collected, not shared, required.',
because:
'The form’s three fields leave the phone for the deployment, whose operator ' +
'receives them.',
}, },
}, },
{ {
id: 'app-content', id: 'app-content',
scope: 'app', scope: 'app',
title: 'Everything you read and post in the app', title: 'What you post and send from the app',
body: body:
'Forum posts, Team activity, what the game module shows you, notification ' + 'Most of the app is reading. What it sends is what you write, and most of that is ' +
'preferences: all of it is a live read or write against the deployment. Apart from ' + 'for staff: news posts, wiki categories, a broadcast to the game, the answer to a ' +
'the notification snapshot described in the next entry, nothing is cached for ' + 'player’s help page, and the account and reason on a kick or a ban. All of it is a ' +
'offline use and nothing is duplicated anywhere else — the app with no signal is ' + 'live write to the deployment. Apart from the notification snapshot described ' +
'an app with almost no content, which is a limitation and also an accurate ' + 'below, nothing is cached for offline use and nothing is duplicated anywhere else ' +
'description of where the data lives.', '— the app with no signal is an app with almost no content, which is a limitation ' +
'and also an accurate description of where the data lives.',
retention: { retention: {
summary: 'Held by the deployment, under its operator’s policy', summary: 'Held by the deployment, under its operator’s policy',
}, },
source: 'PLAN.md §9 section 2', source: 'data/api/AdminApi.kt',
play: { play: {
category: 'Messages', types: [
type: 'Other user-generated content', { category: 'Messages', type: 'Other in-app messages' },
collected: false, { category: 'App activity', type: 'Other user-generated content' },
],
collected: true,
shared: false, shared: false,
answer: 'Not collected by us.', required: true,
purposes: ['App functionality'],
answer: 'Collected, not shared, required.',
because: because:
'Content is written to the community’s own installation. We have no copy, no ' + 'Posts, broadcasts and replies are written to the community’s own installation. ' +
'access and no way to obtain one.', 'We have no copy, no access and no way to obtain one.',
},
},
{
id: 'app-interactions',
scope: 'app',
title: 'Which notifications you have read, and which you want',
body:
'Opening a notification marks it read on the deployment, so the badge agrees on ' +
'every device you use. Your choice of notification streams, and of which arrive by ' +
'push or email, is saved there too.',
retention: {
summary: 'Held by the deployment, under its operator’s policy',
},
source: 'data/api/NotificationsApi.kt',
play: {
types: [{ category: 'App activity', type: 'App interactions' }],
collected: true,
shared: false,
required: true,
purposes: ['App functionality', 'Personalization'],
answer: 'Collected, not shared, required.',
because:
'Read state and preferences are actions in the app that are sent to, and kept ' +
'by, the deployment.',
},
},
{
id: 'app-search',
scope: 'app',
title: 'What you search for',
body:
'Searching the wiki, a game’s market or its creature atlas sends what you typed ' +
'to the deployment, because the deployment does the searching. The app keeps no ' +
'history of searches.',
retention: {
summary: 'Under the deployment operator’s policy',
detail:
'The query is part of the request’s address, so a deployment’s access log may ' +
'record it. That is why it is declared rather than called ephemeral.',
},
source: 'data/api/PublicApi.kt',
play: {
types: [{ category: 'App activity', type: 'In-app search history' }],
collected: true,
shared: false,
required: true,
purposes: ['App functionality'],
answer: 'Collected, not shared, required.',
because: 'Search text leaves the phone in the request to the deployment.',
}, },
}, },
{ {
@@ -328,15 +555,13 @@ export const collected = [
}, },
source: 'core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt', source: 'core/inbox/DataStoreInboxCache.kt, data/repository/AuthRepository.kt',
play: { play: {
category: 'Messages', types: [],
type: 'Other in-app messages',
collected: false, collected: false,
shared: false, shared: false,
answer: 'Not collected by us. Stored on the device only.', answer: 'Not collected. Stored on the device only.',
because: because:
'The snapshot is written on the phone from data the deployment had already ' + 'The snapshot is written on the phone from data the deployment had already ' +
'delivered. It is not uploaded anywhere, and no server we operate is on either ' + 'delivered. It is not uploaded anywhere.',
'end of it.',
}, },
}, },
{ {
@@ -351,14 +576,13 @@ export const collected = [
retention: { summary: 'Nothing to retain' }, retention: { summary: 'Nothing to retain' },
source: 'app/build.gradle.kts, app/src/main/AndroidManifest.xml', source: 'app/build.gradle.kts, app/src/main/AndroidManifest.xml',
play: { play: {
category: 'Device or other IDs', types: [{ category: 'App info and performance', type: 'Crash logs' }],
type: 'Device or other IDs',
collected: false, collected: false,
shared: false, shared: false,
answer: 'Not collected.', answer: 'Not collected. The advertising ID declaration is "no".',
because: because:
'No advertising ID, no analytics identifier, and no library that would generate ' + 'No crash reporter, no advertising ID, no analytics identifier, and no library ' +
'one is linked into the build.', 'that would generate one is linked into the build.',
}, },
}, },

View File

@@ -29,7 +29,7 @@ export const legal = {
* from git: a build timestamp would move on every rebuild and tell a reader nothing, * from git: a build timestamp would move on every rebuild and tell a reader nothing,
* and a commit date would move when a stylesheet changed. * and a commit date would move when a stylesheet changed.
*/ */
lastUpdated: '2026-09-01', lastUpdated: '2026-10-29',
/** /**
* The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31). * The minimum age to sign up for the beta. The org lead's decision, 2026-08-24 (D31).

View File

@@ -12,8 +12,9 @@
* as "we keep this forever" or "we keep nothing", depending on the reader; * as "we keep this forever" or "we keep nothing", depending on the reader;
* - an `app`-scoped row with no Play mapping means a question on the console form gets * - an `app`-scoped row with no Play mapping means a question on the console form gets
* answered from memory, which is the exact failure D33 exists to prevent; * answered from memory, which is the exact failure D33 exists to prevent;
* - a row that claims we collect or share something contradicts the premise the whole * - a row that claims the app shares something contradicts the premise the whole page
* page rests on, and would be a real disclosure defect rather than a typo; * rests on, and would be a real disclosure defect rather than a typo;
* - a data type that is not the console's own, which the form then never asks about;
* - the consent sentence and /terms stating different minimum ages, which is the kind of * - the consent sentence and /terms stating different minimum ages, which is the kind of
* inconsistency a reviewer finds and a developer never does. * inconsistency a reviewer finds and a developer never does.
* *
@@ -32,6 +33,8 @@ import {
assertScopeNonEmpty, assertScopeNonEmpty,
collected, collected,
collectedIn, collectedIn,
PLAY_DATA_TYPES,
PLAY_PURPOSES,
playRows, playRows,
} from '../src/data/collection.mjs'; } from '../src/data/collection.mjs';
import { legal } from '../src/data/legal.mjs'; import { legal } from '../src/data/legal.mjs';
@@ -74,34 +77,64 @@ describe('the Play Data Safety mapping', () => {
it('answers every mapped question completely', () => { it('answers every mapped question completely', () => {
for (const row of playRows()) { for (const row of playRows()) {
const { category, type, answer, because, collected: isCollected, shared } = row.play; const { types, answer, because, collected: isCollected, shared } = row.play;
assert.ok(category?.trim() && type?.trim(), `${row.id} has no console category/type`); assert.ok(Array.isArray(types), `${row.id}.play.types is not a list`);
assert.ok(answer?.trim() && because?.trim(), `${row.id} has no answer or reasoning`); assert.ok(answer?.trim() && because?.trim(), `${row.id} has no answer or reasoning`);
assert.equal(typeof isCollected, 'boolean', `${row.id}.play.collected is not a boolean`); assert.equal(typeof isCollected, 'boolean', `${row.id}.play.collected is not a boolean`);
assert.equal(typeof shared, 'boolean', `${row.id}.play.shared is not a boolean`); assert.equal(typeof shared, 'boolean', `${row.id}.play.shared is not a boolean`);
} }
}); });
/*
* The console's own words, or nothing. A type filed under the wrong category reads fine
* in the generated table and is simply not on the form, so whoever fills it in either
* skips it or guesses where it went.
*/
it('uses only the console’s categories, types and purposes', () => {
for (const row of playRows()) {
for (const { category, type } of row.play.types) {
assert.ok(PLAY_DATA_TYPES[category], `${row.id}: "${category}" is not a console category`);
assert.ok(
PLAY_DATA_TYPES[category].includes(type),
`${row.id}: "${type}" is not a data type under "${category}"`
);
}
for (const purpose of row.play.purposes ?? []) {
assert.ok(PLAY_PURPOSES.includes(purpose), `${row.id}: "${purpose}" is not a console purpose`);
}
}
});
/*
* The form asks three more questions of every collected type, and a collected row with
* no type is data leaving the phone that the form never hears about.
*/
it('answers the follow-up questions for everything collected', () => {
for (const row of playRows().filter((r) => r.play.collected)) {
assert.ok(row.play.types.length, `${row.id} is collected but names no console type`);
assert.equal(typeof row.play.required, 'boolean', `${row.id}.play.required is not a boolean`);
assert.ok(row.play.purposes?.length, `${row.id} is collected but gives no purpose`);
}
});
/* /*
* The one assertion here that is about the product rather than the shape of the data. * The one assertion here that is about the product rather than the shape of the data.
* *
* "We operate no server the app talks to" is the premise of /privacy section 2, of the * What the app sends is collected in Play's sense, and declared so: it leaves the phone
* generated declaration, and of the argument for why the app needs no account with us. * for the deployment the user chose. What stays true is the premise of /privacy section 2,
* If that ever stops being true — a telemetry endpoint, a crash reporter, a hosted * that none of it reaches us and none of it is handed on. A row that says otherwise — a
* directory of deployments — the honest change is a `collected: true` row AND a rewrite * telemetry endpoint, a crash reporter, a hosted directory of deployments — means that
* of the page's second section. This test makes the first impossible without noticing * premise has changed, and the page has to change with it rather than gaining a row that
* the second, by failing with the reason rather than the diff. * contradicts its own lede.
*/ */
it('holds the premise the whole section rests on', () => { it('holds the premise the whole section rests on', () => {
for (const row of playRows()) { for (const row of playRows()) {
assert.equal( assert.equal(
row.play.collected, row.play.shared,
false, false,
`${row.id} says we collect it. If that is now true, /privacy section 2's premise — ` + `${row.id} says the app shares it. If that is now true, /privacy section 2's ` +
'that we operate no server the app talks to — has changed, and the page has to ' + 'premise — that the app talks only to the deployment the user chose — has changed.'
'change with it rather than gaining a row that contradicts its own lede.'
); );
assert.equal(row.play.shared, false, `${row.id} says we share it — see above.`);
} }
}); });
}); });