fix(legal): declare what the app sends as collected, not shared (D84)
All checks were successful
PR checks / checks (pull_request) Successful in 2m33s

Play counts data that leaves the device as collected, whoever runs the
server it lands on. The generated Data Safety notes answered "not
collected" because we run no server, which is true of /privacy and the
wrong answer to the form (org lead, 2026-10-29, Android PLAN.md M8).

An audit of the app's edge (every body, query and header in data/api/)
found more leaving the phone than section 2 listed. New rows: the
User-Agent, game-account links and creation, the Contact form, staff
posts and moderation, notification read state and preferences, search
text. The sign-in and trusted-device rows now say what is sent (the
credentials, the device's make and model).

- PlayMapping: a list of console types, plus required and purposes,
  checked against PLAY_DATA_TYPES / PLAY_PURPOSES (the first version
  filed "Other user-generated content" under Messages).
- The generator answers per data type, lists every type not collected,
  and answers the form's security questions.
- The premise test now asserts what is still true: nothing is shared.
- legal.lastUpdated moves to 2026-10-29 with /privacy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-29 08:20:25 -05:00
parent 04e8c14d1f
commit d6d5a70ed4
6 changed files with 606 additions and 139 deletions

View File

@@ -12,8 +12,9 @@
* 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
* 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
* page rests on, and would be a real disclosure defect rather than a typo;
* - a row that claims the app shares something contradicts the premise the whole page
* 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
* inconsistency a reviewer finds and a developer never does.
*
@@ -32,6 +33,8 @@ import {
assertScopeNonEmpty,
collected,
collectedIn,
PLAY_DATA_TYPES,
PLAY_PURPOSES,
playRows,
} from '../src/data/collection.mjs';
import { legal } from '../src/data/legal.mjs';
@@ -74,34 +77,64 @@ describe('the Play Data Safety mapping', () => {
it('answers every mapped question completely', () => {
for (const row of playRows()) {
const { category, type, answer, because, collected: isCollected, shared } = row.play;
assert.ok(category?.trim() && type?.trim(), `${row.id} has no console category/type`);
const { types, answer, because, collected: isCollected, shared } = row.play;
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.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`);
}
});
/*
* 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.
*
* "We operate no server the app talks to" is the premise of /privacy section 2, of the
* generated declaration, and of the argument for why the app needs no account with us.
* If that ever stops being true — a telemetry endpoint, a crash reporter, a hosted
* directory of deployments — the honest change is a `collected: true` row AND a rewrite
* of the page's second section. This test makes the first impossible without noticing
* the second, by failing with the reason rather than the diff.
* What the app sends is collected in Play's sense, and declared so: it leaves the phone
* for the deployment the user chose. What stays true is the premise of /privacy section 2,
* that none of it reaches us and none of it is handed on. A row that says otherwise — a
* telemetry endpoint, a crash reporter, a hosted directory of deployments — means that
* premise has changed, and the page has to change with it rather than gaining a row that
* contradicts its own lede.
*/
it('holds the premise the whole section rests on', () => {
for (const row of playRows()) {
assert.equal(
row.play.collected,
row.play.shared,
false,
`${row.id} says we collect it. If that is now true, /privacy section 2's premise — ` +
'that we operate no server the app talks to — has changed, and the page has to ' +
'change with it rather than gaining a row that contradicts its own lede.'
`${row.id} says the app shares it. If that is now true, /privacy section 2's ` +
'premise — that the app talks only to the deployment the user chose — has changed.'
);
assert.equal(row.play.shared, false, `${row.id} says we share it — see above.`);
}
});
});