Skip to content

docs(legal): a privacy policy that matches the code, one retention per store - #11217

Merged
MarkusNeusinger merged 3 commits into
mainfrom
docs/legal-privacy-audit
Sep 3, 2026
Merged

docs(legal): a privacy policy that matches the code, one retention per store#11217
MarkusNeusinger merged 3 commits into
mainfrom
docs/legal-privacy-audit

Conversation

@MarkusNeusinger

@MarkusNeusinger MarkusNeusinger commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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 listanyplot-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 daysgcloud logging buckets describe _Default --location=global --project=anyplotretentionDays: 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 dashboardhttps://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

  • yarn type-check (tsc + tsconfig.test.json) — clean
  • yarn lint — clean
  • yarn fm:check — clean
  • 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)
  • uv run pytest -k seo_legal — passes; the test now also asserts the old wrong sentence is gone
  • uv run ruff check + ruff format --check on the touched Python — clean
  • uv run python -m tools.changelog check --base origin/main — fragment well-formed

🤖 Generated with Claude Code

https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3

…r store

The privacy section had drifted behind a year of features. It promised "no
personal data (names, emails, etc.)" while the feedback widget asks for exactly
that and keeps it; it named no legal basis and no jurisdiction; its rights list
stopped at access/rectify/erase/export; and nothing said that Cloudflare
processes the visitor's IP at the edge.

Each store now carries the retention that is true for it — Cloud Logging's 30
days, a feedback entry until it is deleted by hand — rather than one blanket
figure. Plausible is described by the property that matters (no cookies, no
cross-site identifier, no IP stored, the browser talking only to our domain)
instead of the false claim that the script is not a third party's.

The crawler body in api/routers/seo.py mirrored the same wrong sentence and is
corrected with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
Copilot AI balanced review requested due to automatic review settings September 3, 2026 22:04
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The policy still omits stored feedback identifiers and retains an inaccurate personal-data claim.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Updates privacy disclosures to match current analytics, feedback, hosting, and retention behavior.

Changes:

  • Expands the interactive privacy policy.
  • Synchronizes crawler-facing legal content.
  • Adds regression tests and a changelog fragment.
File summaries
File Description
app/src/pages/LegalPage.tsx Revises privacy disclosures and rights.
app/src/pages/LegalPage.test.tsx Tests key legal claims and retention rules.
api/routers/seo.py Updates crawler-facing privacy content.
tests/unit/api/test_routers.py Tests crawler legal output.
changelog.d/legal-privacy-audit.md Records the policy audit.
Review details

Suppressed comments (2)

app/src/pages/LegalPage.tsx:255

  • This remains a blanket false claim: the policy itself says that server logs collect IP addresses and user agents, Cloudflare processes IP addresses, and feedback automatically stores an IP hash and session identifier. Replace this with the narrower name/email claim, and update the test at LegalPage.test.tsx:81, which currently pins the inaccurate wording.
              • no personal data unless you type it — the optional contact field in the feedback
              widget is the only one that asks for a name or an email

app/src/pages/LegalPage.tsx:258

  • The localStorage inventory omits anyplot_feedback_session, which FeedbackWidget.tsx:25,144-149 persists and sends with feedback. Add this random feedback session identifier to the list so the newly sharpened storage claim is complete.
              • no cookies at all (localStorage holds UI preferences — theme, image size — and a
              cached release number)
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/src/pages/LegalPage.tsx Outdated
Comment thread tests/unit/api/test_routers.py
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…data line

Four findings from the Copilot review, all of them right:

* The feedback paragraph read as an exhaustive list but left out the window
  size and the random session id the widget sends with every submission
  (FeedbackWidget.tsx:213-214). Both are named now.
* "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 same page says three paragraphs
  earlier that all three are stored. The bullet now promises only what it can:
  no name or email unless you type one.
* The localStorage inventory listed preferences and a cached release number,
  but not `anyplot_feedback_session` — a persistent random identifier, which
  is the one entry a reader would want listed.
* The crawler-body test claimed to pin both retentions and asserted only the
  30 days. It now asserts the feedback one too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UEScQMZFvxxNNyNJYryfa3
@MarkusNeusinger
MarkusNeusinger merged commit aeac5c3 into main Sep 3, 2026
15 checks passed
@MarkusNeusinger
MarkusNeusinger deleted the docs/legal-privacy-audit branch September 3, 2026 22:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants