You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs(legal): a privacy policy that matches the code, one retention per store (#11217)
## Summary
- **The privacy section now says what the code does.** It had drifted
behind a year of features; every claim in it (and the services table
behind it) was checked against the current code and infrastructure, and
what no longer held was fixed or dropped. Same lowercase house voice,
same length class — the section grew by three short paragraphs, not by
an article-by-article rewrite.
- **One retention per store, none borrowed.** Server logs get Cloud
Logging's 30 days; a feedback entry gets "nothing deletes them on a
timer — ask and yours is gone". A single blanket figure is the qualifier
a shortening pass loses first, so the tests pin both separately.
- **The crawler body in `api/routers/seo.py` carried the same wrong
sentence** ("no personal data collected") and is corrected in the same
commit — that string is anyplot's prerender for `/legal`, and its own
comment asks for the two to stay in sync.
## What was added
| | |
|---|---|
| legal basis + jurisdiction | one sentence under `data controller`: an
operator's legitimate interest in protecting the site and knowing how
often it is read; Swiss data protection law, GDPR on top for EU
visitors. No article numbers, no EU-representative claim. |
| feedback retention | a new `feedback.` paragraph: every field the
widget stores (message, reaction, contact, page, window size, user
agent, submission id, IP hash), admin-only, no timer |
| the edge | a new `the edge.` paragraph: Cloudflare processes the IP
for rate limiting, bot filtering (which can show a check page) and DDoS
|
| rights | access, rectification, erasure, restriction, objection **on
grounds relating to your particular situation**, complaint to a
supervisory authority — one sentence, no article numbers |
## What was wrong and is now fixed
Each claim with the evidence that decided it:
| Claim as it stood | Verdict | Evidence |
|---|---|---|
| "no personal data (names, emails, etc.)" | **false** → "no name or
email unless you type one — the optional contact field in the feedback
widget is the only place that asks" |
`app/src/components/FeedbackWidget.tsx:485` placeholder is literally
`Name or email (optional)`; stored at `core/database/models.py:250`
(`contact`), alongside `message`, `viewport`, `user_agent`,
`session_id`, `ip_hash` (`models.py:248–259`). The promise is
deliberately *only* name/email: an IP address, its hash and the feedback
session id are personal data whether or not anyone typed them, so any
broader "no personal data" wording would be the same false claim again
(Copilot review) |
| Plausible "collects no personal data … a privacy-focused analytics
tool" | **wrong property** → no cookies, no cross-site identifier, no IP
kept there, the browser talks only to us | the script IS Plausible's,
only served from our domain: `app/nginx.conf:321–323` proxies
`/js/script.js`, `:335` proxies `/api/event`; `app/index.html:143,145` |
| "all services are hosted in the EU (Netherlands, europe-west4)" |
**over-broad** → the site, database and images run in europe-west4;
Cloudflare's network is global; Plausible is its own EU service |
`api/cloudbuild.yaml:4` `_REGION: europe-west4`, `:66`
`anyplot:europe-west4:anyplot-db`; `gcloud storage buckets list` →
`anyplot-images`, `anyplot-static` both `EUROPE-WEST4` |
| "we track … UI interactions (tab toggles, theme preference, **banner
dismissals**)" | **stale** → list shortened to what is actually sent |
the tracked event names are `copy_code, download_image, external_link,
feedback_opened, feedback_submitted, internal_link, library_click,
library_filter, map_*, nav_click, page_not_found, plot_rotate,
report_issue, search_no_results, stats_top_impl_click, suggest_spec,
tag_click, theme_toggle` — no banner event exists |
| "since we do not store personal data, there is typically nothing to
delete or export" | **false**, and `export` was the wrong right |
feedback entries exist; and portability applies to consent/contract
processing, not to the legitimate interest this site runs on, so the
export claim was dropped rather than restated |
| "no cookies at all (localStorage for UI preferences only)" | **true,
but the inventory was short** | no `Set-Cookie` from `anyplot.ai` or
`api.anyplot.ai` (curl, 2026-09-03). localStorage keys: `theme`
(`useThemeMode.ts:3`), `imageSize` (`PlotsPage.tsx:100`),
`anyplot:latest-release` (`useLatestRelease.ts:4`) and
`anyplot_feedback_session` (`FeedbackWidget.tsx:25,144`). The last two
are not preferences — one is a cached release number, the other a
persistent random identifier — so both are named (Copilot review) |
| "no ai training: your interactions are not used to train ai models" |
**true, sharpened** | Anthropic is called only from
`scripts/evaluate-plot.py:500` (CI/generation time). `grep anthropic
api/ core/` finds only the unused `core/config.py:79` key — nothing on a
request path. The bullet now says so |
| bot body: "no personal data collected" (`api/routers/seo.py`) |
**false** → same per-store wording as the human page | same evidence as
row 1 |
| "last updated: July 2026" | stale → September 2026 | |
## What was checked and left alone
- **server logs, 30 days** — `gcloud logging buckets describe _Default
--location=global --project=anyplot` → `retentionDays: 30`. This is the
configured value (and also GCP's default), so the sentence stands as
written.
- **the services table** — every row verified accurate (Cloud Run, Cloud
SQL, Cloud Storage all `europe-west4`; Cloudflare global with EU data
centres; Plausible EU + proxied). Only the sentence introducing it was
over-broad.
- **the public Plausible dashboard** — `https://plausible.io/anyplot.ai`
answers 200.
- **GitHub username credit in spec metadata** — real:
`plots/*/specification.yaml` carries `suggested: <github handle>`.
Paragraph kept unchanged.
- **CSP violation reports** — anyplot has **no** CSP reporting:
`app/security-headers.conf:28` sets no `report-uri`/`report-to`, and the
only `report-to` header on the live site is Cloudflare's own NEL
endpoint, which reports to Cloudflare, not to us. Nothing was written
about it, since there is nothing to write. (The sibling repo does have
one; this is the asymmetry, not an omission.)
- **an in-process rate-limit counter** — anyplot has none. The only rate
limit is the feedback endpoint's, and it is DB-backed via the stored
`ip_hash` (`api/routers/feedback.py:96–101`), so it is covered by the
feedback paragraph rather than getting one of its own.
## For Markus — please confirm
1. **The jurisdiction sentence.** "swiss data protection law applies,
and the GDPR on top for visitors from the EU", with no EU representative
named. That is the intended position, as agreed for kurrentschrift — say
the word if anyplot should read differently.
2. **The one figure I could not verify from the repository:** the 30-day
log retention comes from the live `_Default` Cloud Logging bucket, not
from any file in the repo. If a sink or a bucket policy is ever changed
in the console, this sentence goes stale silently — there is no test
that can catch it.
3. **The feedback contact field is a genuine personal-data intake nobody
had documented.** Free text plus "Name or email (optional)" is stored
indefinitely and read only through the admin `/debug` routes
(`api/routers/debug.py:547,560,599`, all `require_admin`). The page now
says so honestly, but if you would rather the field stopped asking for
an email, or entries got an automatic expiry, that is a code change and
a separate PR.
## Decisions taken here
- Kept the existing skeleton (`data controller` · `what we collect` ·
`what we do not collect` · `hosting & third parties` · `your rights`)
rather than adding sub-headings — the brief asked for a focused update,
not a new page.
- Folded the legal basis into `data controller` instead of giving it its
own heading; one sentence did not earn one.
- Dropped the right to data portability rather than restating it, per
the reasoning in the table above.
- Named neither article numbers nor per-region service essays anywhere.
## Review round
One Copilot review, four findings, all legitimate and all applied in
`6f6b610`: the feedback field list was missing the window size and the
submission id; "no personal data unless you type it" was the old blanket
claim in new words (an IP address, its hash and a persistent random id
are personal data whether or not anyone typed them, and the page says so
three paragraphs earlier); the localStorage inventory omitted
`anyplot_feedback_session`; and the crawler-body test claimed to pin
both retentions while asserting only the 30 days. Both threads answered
and resolved.
## Test plan
- [x] `yarn type-check` (tsc + tsconfig.test.json) — clean
- [x] `yarn lint` — clean
- [x] `yarn fm:check` — clean
- [x] `yarn test` — 70 files, 626 tests pass; `LegalPage.test.tsx` grew
from 9 to 12 cases, pinning the legal basis + jurisdiction, the
per-store retentions, and the corrected claims (including the two
feedback identifiers, so a later trim cannot drop them)
- [x] `uv run pytest -k seo_legal` — passes; the test now also asserts
the old wrong sentence is gone
- [x] `uv run ruff check` + `ruff format --check` on the touched Python
— clean
- [x] `uv run python -m tools.changelog check --base origin/main` —
fragment well-formed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
0 commit comments