From 6ab9e3ec50af2e0bc66d2d3c6a41907fa771f806 Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Mon, 2 Nov 2026 22:37:58 +0100
Subject: [PATCH 1/6] docs(legal): cut the legal page by half and state only
the current state
The privacy policy drops from 1,546 to 762 visible words and the page from
1,805 to 983. Each fact is said once, in short fact tables: what is
processed, where it runs, how long each store keeps it, the three legal
bases, and the data assistant's terms. The notes on log entries written
before the move to europe-west4 and on older unsalted feedback hashes are
gone; both conditions ended on 2026-11-02.
The crawler body mirrors the same facts in 344 words instead of 370, now
with all three legal bases, the rights, the telemetry retention and the
no-training line. Tests pin each store's retention by table row and assert
that the history notes stay gone.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
---
api/routers/seo.py | 53 +++--
app/src/pages/LegalPage.test.tsx | 139 ++++++-----
app/src/pages/LegalPage.tsx | 381 +++++++++++++------------------
changelog.d/legal-page-short.md | 8 +
tests/unit/api/test_routers.py | 15 +-
5 files changed, 298 insertions(+), 298 deletions(-)
create mode 100644 changelog.d/legal-page-short.md
diff --git a/api/routers/seo.py b/api/routers/seo.py
index 0b410c9c23..5cba9f6ad9 100644
--- a/api/routers/seo.py
+++ b/api/routers/seo.py
@@ -1205,28 +1205,39 @@ async def seo_libraries():
'X). Full legal notice with '
'contact details on the interactive page.
'
"
Privacy
"
- "
Analytics: Plausible Analytics (EU), served over a path on this domain — no cookies, no "
- "cross-site identifier, no IP address stored. Server logs are kept 30 days in Google Cloud Logging "
- "in the Netherlands (europe-west4); entries written before the move on 2 November 2026 stay in "
- "Google's global log storage until their 30 days are over. A feedback entry "
- "is kept until it is deleted by hand and is readable only by the operator, and the salted "
- "hash of the IP address that limits feedback spam is cleared after ten minutes. Hosting: "
- "Google Cloud Run (Netherlands), with Cloudflare at the edge.
"
+ "
"
+ "
Swiss data protection law applies, and the GDPR for visitors from the EU.
"
+ "
Analytics: Plausible Analytics (EU), through this domain; no cookies, no cross-site id, "
+ "no IP address stored.
"
+ "
Hosting: Google Cloud Run, SQL and Storage in the Netherlands (europe-west4), Cloudflare at "
+ "the edge. Server logs: kept 30 days in Google Cloud Logging in the Netherlands "
+ "(europe-west4).
"
+ "
Feedback: read only by the operator, kept until you ask to delete it; the salted IP hash "
+ "against spam is cleared after ten minutes.
"
+ "
No cookies for visitors (admins: the Cloudflare Access login cookie); no AI training on "
+ "what you send.
"
+ "
Legal bases: legitimate interest (Art. 6(1)(f) GDPR) for the site and the assistant's "
+ "telemetry and limits; your request (Art. 6(1)(b) GDPR) for a plot; consent (Art. 6(1)(a) GDPR) "
+ "for a feedback case, withdrawable at any time.
"
+ "
Google Cloud and Cloudflare apply their data processing terms, with safeguards for "
+ "transfers outside Switzerland and the EU.
"
+ "
Rights: access, correction, erasure, restriction, objection, and a complaint to a "
+ "supervisory authority.
"
+ "
"
'
Data assistant (experimental)
'
- "
Only if you use it: the data assistant adapts a catalogue plot to data you bring. It is "
- "open to everyone as an experimental feature, and the operator may pause it at any time. What "
- "you paste and write goes to Claude models by Anthropic through Google Cloud Vertex AI in the "
- "EU; results can be wrong, and nobody checks them by hand. Sessions live in the server's memory "
- "only and end after an hour without activity. Its log lines carry no content, only a random id "
- "of your browser tab salted with a value that lives as long as the server instance; the limits "
- "per network count your IP address the same way, in memory only. Nothing is stored unless you "
- "send feedback: then the conversation, the plots, the code and a 5-row sample of your data, and "
- "the whole data file only if you tick the box (it starts unticked), go to a private bucket for "
- "180 days. A feedback case rests on your consent (Art. 6(1)(a) GDPR), given when you send it, "
- "which you can withdraw at any time by asking. The code it writes is yours to use, as is; Highcharts is free for non-commercial use, "
- "and a commercial use needs a licence from Highsoft. Google Cloud and Cloudflare process data "
- "under their data processing terms, including their safeguards for transfers outside "
- "Switzerland and the EU.
"
+ "
"
+ "
Only if you use it: open to everyone as an experimental feature, and the operator may "
+ "pause it at any time. No warranty: results can be wrong, and nobody checks them by hand.
"
+ "
Input goes to Claude by Anthropic via Google Cloud Vertex AI in the EU, never used for "
+ "training.
"
+ "
Sessions: server memory only, cleared after an hour idle.
"
+ "
Logs and telemetry hold no content; ids and IP addresses are salted with a value that lives "
+ "as long as the server instance. Telemetry: up to 180 days.
"
+ "
Stored only with feedback: the conversation, plots, code, a 5-row sample, and the data file "
+ "only if you tick the box (it starts unticked); private bucket, 180 days.
"
+ "
Its code is yours to use, as is; Highcharts is free for non-commercial use, and a commercial "
+ "use needs a licence from Highsoft.
"
+ "
"
"
Transparency
"
"
The whole stack — specs, pipeline, API and frontend — is open source at "
'github.com/MarkusNeusinger/anyplot; '
diff --git a/app/src/pages/LegalPage.test.tsx b/app/src/pages/LegalPage.test.tsx
index e6808fe892..14f8b5e094 100644
--- a/app/src/pages/LegalPage.test.tsx
+++ b/app/src/pages/LegalPage.test.tsx
@@ -64,16 +64,29 @@ describe('LegalPage', () => {
it('gives each store its own retention, and no store a borrowed one', () => {
render();
- // Cloud Logging has the 30 days, in the EU since 2026-11-02; the feedback table has no timer at all.
- expect(screen.getByText(/kept for 30 days in/)).toBeInTheDocument();
+ // One table row per store, each with its own figure: the 30 days belong to
+ // the server logs only, and the feedback rows have no timer at all.
+ const retention: [string, string][] = [
+ ['server logs', '30 days'],
+ ['feedback IP hash', 'ten minutes'],
+ ['assistant telemetry', 'up to 180 days'],
+ ['feedback case files', '180 days'],
+ ['feedback entries, case rows', 'until you ask to delete them'],
+ ];
+ for (const [store, kept] of retention) {
+ expect(
+ screen.getByRole('row', { name: new RegExp(`^${store}\\s*${kept}$`) })
+ ).toBeInTheDocument();
+ }
+ // Google Cloud, its logs included, runs in the EU region.
expect(
- screen.getByText(/in the Netherlands \(europe-west4\), for security and debugging purposes/)
- ).toBeInTheDocument();
- // The move happened mid-day, so the cut is the move itself, not the date.
- expect(
- screen.getByText(/written before the move on 2 November 2026 stay in Google.s global/)
+ screen.getByText(
+ /Google Cloud \(Run, SQL, Storage, Logging\) in the Netherlands \(europe-west4\)/
+ )
).toBeInTheDocument();
- expect(screen.getByText(/nothing deletes them on a timer/)).toBeInTheDocument();
+ // The page states the current state only: no notes on what older entries still carry.
+ expect(screen.queryByText(/before the move/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/unsalted/)).not.toBeInTheDocument();
});
it('does not claim more privacy than the code delivers', () => {
@@ -88,70 +101,78 @@ describe('LegalPage', () => {
// quietly drop the two identifiers a reader would care about most.
expect(screen.getByText(/your window size/)).toBeInTheDocument();
expect(
- screen.getByText(/a random id that ties your own submissions\s+together/)
+ screen.getByText(/a random id that ties your own submissions together/)
).toBeInTheDocument();
- // The feedback's IP hash is salted and short-lived, and the older rows are named, not hidden.
- expect(screen.getByText(/clears it after ten minutes/)).toBeInTheDocument();
- expect(screen.getByText(/older, unsalted hash until it is cleared/)).toBeInTheDocument();
+ // The feedback's IP hash is salted; its ten minutes sit in the retention table.
+ expect(screen.getByText(/a salted hash of your IP address limits spam/)).toBeInTheDocument();
// Plausible's real property is cookieless and identifier-free, not that
// the script is ours — it is Plausible's, only served from our domain.
- expect(screen.getByText(/the script is theirs/)).toBeInTheDocument();
- expect(screen.getByText(/Cloudflare stands in front of the site/)).toBeInTheDocument();
+ expect(screen.getByText(/its script is served through this domain/)).toBeInTheDocument();
+ expect(screen.getByText(/Cloudflare uses your IP address/)).toBeInTheDocument();
+ // The browser's storage is named, not hidden behind "no cookies".
+ expect(screen.getByText(/your plot\s+votes, a release number/)).toBeInTheDocument();
});
// The data assistant's section, each claim pinned with the qualifier that
// makes it true: experimental and open to everyone but pausable, training
- // excluded only under Vertex AI's terms, telemetry pseudonymous (not
- // anonymous), the data file unticked by default, and a case row that has no
- // timer while its files do.
+ // excluded under Google Cloud's terms, the ids pseudonymous (not anonymous),
+ // the data file unticked by default, and each legal basis with its purpose.
it('describes the experimental data assistant without overclaiming', () => {
render();
expect(
- screen.getByText(
- /open to everyone as an\s+experimental feature, and the operator may pause it/
- )
+ screen.getByText(/open to everyone, experimental and without warranty/)
).toBeInTheDocument();
+ expect(screen.getByText(/nobody checks them by hand/)).toBeInTheDocument();
+ expect(screen.getByText(/the operator may pause it at any time/)).toBeInTheDocument();
expect(
- screen.getByText(/served through Google Cloud Vertex AI in the\s+EU/)
+ screen.getByText(
+ /served through Google Cloud Vertex AI in the EU under Google Cloud.s data processing terms/
+ )
).toBeInTheDocument();
+ expect(screen.getByText(/your prompts are not used for training/)).toBeInTheDocument();
expect(
- screen.getByText(/processed under Google Cloud.s data\s+processing terms/)
+ screen.getByText(/don.t paste personal data you are not allowed to share/)
).toBeInTheDocument();
expect(
- screen.getByText(/don.t paste personal data you are not allowed to\s+share/)
+ screen.getByText(/with no database, and is cleared after an hour idle/)
).toBeInTheDocument();
- expect(screen.getByText(/there is no session database/)).toBeInTheDocument();
- expect(screen.getByText(/pseudonymous, not\s+anonymous/)).toBeInTheDocument();
- // The language is about the user: named, and only as a code with rare ones folded into "other".
+ // The renderer's answer cache has no idle timer of its own.
+ expect(screen.getByText(/keeps its last 16 results in memory/)).toBeInTheDocument();
+ expect(screen.getByText(/never your text, data, column or file names/)).toBeInTheDocument();
+ // The language is about the user: only as a code, with rare ones folded into "other".
+ expect(screen.getByText(/language code \(rare ones as "other"\)/)).toBeInTheDocument();
+ // A public user's id is a salted random tab id that dies with the instance; only admins get the HMAC.
expect(
screen.getByText(
- /language as a code \(one of a dozen common languages, any\s+other only as "other"\)/
+ /salted with a value that lives only as long as the server instance, in memory, never written down/
)
).toBeInTheDocument();
- expect(screen.getByText(/up to 180 days/)).toBeInTheDocument();
- // A public user's id is a salted random tab id that dies with the instance; only admins get the HMAC.
+ expect(screen.getByText(/keyed hash \(HMAC\)/)).toBeInTheDocument();
+ // The 30-day server logs could only be joined by timestamp: pseudonymous, not anonymous.
expect(
- screen.getByText(/salted with a value that lives only as\s+long as the server instance/)
+ screen.getByText(/only timestamps could match them to the 30-day server logs/)
+ ).toBeInTheDocument();
+ expect(screen.getByText(/so they are pseudonymous/)).toBeInTheDocument();
+ // Nothing is kept without feedback; the box starts unticked, and leaving it so drops
+ // data.csv only, while the profile sample stays.
+ expect(screen.getByText(/kept only when you send feedback/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/it starts unticked, which leaves out that file only/)
+ ).toBeInTheDocument();
+ expect(screen.getByText(/first 5 rows/)).toBeInTheDocument();
+ // Each legal basis with what it covers; consent given at the send and withdrawable.
+ expect(
+ screen.getByRole('row', { name: /^Art\. 6\(1\)\(f\)\s*legitimate interest/ })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('row', { name: /^Art\. 6\(1\)\(b\)\s*your request: making the plot/ })
).toBeInTheDocument();
- expect(screen.getByText(/keyed hash \(HMAC\)/)).toBeInTheDocument();
- expect(screen.getByText(/salted, in memory only, never written down/)).toBeInTheDocument();
- // The box starts unticked, and leaving it so drops data.csv only; the profile sample stays.
- expect(screen.getByText(/it starts\s+unticked/)).toBeInTheDocument();
- expect(screen.getByText(/leaving it unticked leaves out that file only/)).toBeInTheDocument();
- expect(screen.getByText(/first 5 rows as a sample/)).toBeInTheDocument();
- // The renderer's answer cache has no idle timer of its own.
- expect(screen.getByText(/keeps its last 16 results/)).toBeInTheDocument();
- expect(screen.getByText(/nothing deletes it on a timer/)).toBeInTheDocument();
- expect(screen.getByText(/Art\. 6\(1\)\(b\) GDPR/)).toBeInTheDocument();
- expect(screen.getByText(/Art\. 6\(1\)\(f\) GDPR/)).toBeInTheDocument();
- // A feedback case rests on consent, given at the send and withdrawable.
expect(
- screen.getByText(/a feedback case rests on your consent \(Art\. 6\(1\)\(a\)\s+GDPR\)/)
+ screen.getByRole('row', {
+ name: /^Art\. 6\(1\)\(a\)\s*your consent to a feedback case, given when you send it and withdrawable at any time by asking$/,
+ })
).toBeInTheDocument();
- expect(screen.getByText(/which you can withdraw at any time by asking/)).toBeInTheDocument();
- // The two 30-day logs could only be joined by timestamp; the salted ids map to nothing else.
- expect(screen.getByText(/could only be lined up by their\s+timestamps/)).toBeInTheDocument();
// "No cookies" holds for visitors; admins get Cloudflare Access's login cookie.
expect(screen.getByText(/no cookies for visitors/)).toBeInTheDocument();
expect(
@@ -160,22 +181,24 @@ describe('LegalPage', () => {
// Scope: the assistant's processing touches only the people who use it; the chat links here.
const heading = screen.getByText('only if you use the data assistant (experimental)');
expect(heading).toHaveAttribute('id', 'assistant');
- // Use, warranty, the code's licence with the Highcharts exception, and the transfers.
- expect(screen.getByText(/comes with no\s+warranty/)).toBeInTheDocument();
- expect(screen.getByText(/nobody checks them by hand/)).toBeInTheDocument();
- expect(screen.getByText(/yours to\s+use, as is/)).toBeInTheDocument();
expect(
- screen.getByText(
- /Highcharts is free for non-commercial use, and a\s+commercial use needs a licence from Highsoft/
- )
+ screen.getByText(/if you only browse the catalogue, none of it touches you/)
).toBeInTheDocument();
- expect(screen.getByText(/transfers\s+outside Switzerland and the EU/)).toBeInTheDocument();
- expect(screen.getByText(/if you\s+only browse the catalogue, none of it/)).toBeInTheDocument();
expect(
- screen.getByText(/Claude models by Anthropic \(EU\) — only when you use the\s+data assistant/)
+ screen.getByRole('row', {
+ name: /^data assistant\s*Claude by Anthropic via Google Cloud Vertex AI \(EU\), if you use it$/,
+ })
+ ).toBeInTheDocument();
+ // The code's licence with the Highcharts exception, and the transfers.
+ expect(screen.getByText(/yours to use, as is/)).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Highcharts is free for non-commercial use, and a commercial use needs a licence from Highsoft/
+ )
).toBeInTheDocument();
- // Browsing the catalogue still involves no model; only the assistant does.
- expect(screen.getByText(/never sees a visitor.s request/)).toBeInTheDocument();
+ expect(screen.getByText(/transfers outside Switzerland and the EU/)).toBeInTheDocument();
+ // Browsing the catalogue involves no model, and nothing sent trains one.
+ expect(screen.getByText(/browsing never reaches a model/)).toBeInTheDocument();
});
it('renders the technology stack', () => {
diff --git a/app/src/pages/LegalPage.tsx b/app/src/pages/LegalPage.tsx
index a92dbe713f..80fe053995 100644
--- a/app/src/pages/LegalPage.tsx
+++ b/app/src/pages/LegalPage.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { type ReactNode, useEffect } from 'react';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
@@ -30,6 +30,24 @@ const firstColStyle = {
const AVATAR_URL = 'https://storage.googleapis.com/anyplot-static/images/markusneusinger.webp';
+/** A two-column fact table: a short label, then the fact. */
+function FactTable({ rows }: { rows: [string, ReactNode][] }) {
+ return (
+
+ );
+}
+
export function LegalPage() {
const { trackPageview, trackEvent } = useAnalytics();
@@ -123,11 +141,9 @@ export function LegalPage() {
<>
disclaimer
- this is a personal portfolio project showcasing data visualization examples, generated and
- maintained through ai-powered workflows. all code examples are meant for inspiration and
- learning — take them as a starting point, adapt them to your data and requirements, or use
- ai tools to customize them for your specific needs. code is provided "as is" under
- the MIT License and should be reviewed before production use.
+ a personal portfolio project: plot examples generated and maintained by ai workflows, meant
+ as a starting point for your own data. the code is provided "as is" under the MIT
+ License; review it before production use.
>
);
@@ -185,232 +201,168 @@ export function LegalPage() {
privacy policy} />
- data controller
+ controller
- Markus Neusinger (see legal notice above). the ground for the site itself is an
- operator's legitimate interest in protecting the site and knowing how often it is
- read; the data assistant below, which only its users meet, names its own grounds.
- swiss data protection law applies, and the GDPR on top for visitors from the EU.
+ Markus Neusinger (see above). swiss data protection law applies, and the GDPR for
+ visitors from the EU.
- what we collect
-
- anonymized analytics. the counting is done by{' '}
-
- Plausible Analytics
-
- . the script is theirs, but it is served over a path on this domain: no cookies, no
- identifier that would recognize you on other sites, no IP address kept there, and your
- browser talks only to us. counted are page views, search queries and filter usage,
- code copies and image downloads, theme and navigation clicks, and Core Web Vitals
- (LCP, CLS, INP) to keep the site fast. when you share a link, we read the requesting
- bot's user-agent to detect the platform (e.g., LinkedIn, WhatsApp) — no data
- about the eventual viewer is collected at that step. all data is aggregated and
- anonymous.
-
-
- public dashboard. our analytics are{' '}
-
- fully public
- {' '}
- — see exactly what we see.
-
-
- server logs. technical server logs including IP addresses, request
- URLs, and user agents are kept for 30 days in{' '}
-
- Google Cloud Logging
- {' '}
- in the Netherlands (europe-west4), for security and debugging purposes. entries
- written before the move on 2 November 2026 stay in Google's global log storage
- until their 30 days are over.
-
-
- feedback. the feedback widget keeps what you send it: the message,
- the reaction, anything you put in the optional contact field, the page you were on,
- your window size, your user-agent, and a random id that ties your own submissions
- together. to limit spam it also keeps a hash of your IP address, salted with a value
- that lives only in the server's memory, and clears it after ten minutes; entries
- sent before November 2026 still carry an older, unsalted hash until it is cleared.
- only the operator can read those entries, and nothing deletes them on a timer — ask
- and yours is gone.
-
-
- the edge. Cloudflare stands in front of the site and processes your
- IP address there to rate-limit, to filter bots — which can put a short check page in
- front of a request — and to absorb DDoS attacks.
-
+ what is processed
+
+
+ Plausible
+ {' '}
+ counts page views, searches, clicks, copies, downloads and Core Web Vitals, only
+ in aggregate, on a{' '}
+
+ public dashboard
+
+ . its script is served through this domain: no cookies, no cross-site id, no IP
+ address kept.
+ >,
+ ],
+ [
+ 'server logs',
+ 'IP address, URL and user-agent of each request, for security and debugging.',
+ ],
+ [
+ 'edge',
+ 'Cloudflare uses your IP address to rate-limit, filter bots and absorb DDoS attacks.',
+ ],
+ [
+ 'feedback',
+ 'message, reaction, optional contact, page, your window size, user-agent and a random id that ties your own submissions together, read only by the operator. a salted hash of your IP address limits spam.',
+ ],
+ [
+ 'github',
+ 'suggest a plot type there and your public username may be credited in its spec.',
+ ],
+ ]}
+ />
-
- only if you use the data assistant (experimental)
-
-
- everything in this part applies only to people who use the data assistant. if you only
- browse the catalogue, none of it touches you. the assistant adapts a catalogue plot to
- data you bring ("use with my data"). it is open to everyone as an
- experimental feature, and the operator may pause it at any time. this is what it does
- with what you give it.
-
-
- what the model sees. what you paste or drop and the messages you
- write go to Claude models by Anthropic, served through Google Cloud Vertex AI in the
- EU, to make the plot you asked for. they are processed under Google Cloud's data
- processing terms, and under Google Cloud's terms for Vertex AI your prompts are
- not used to train the models. don't paste personal data you are not allowed to
- share.
-
-
- sessions. a session — your data, the conversation and the plots made
- from it — is held in the server's memory only. it is cleared after an hour
- without activity or when the server instance stops; there is no session database. the
- separate render service also keeps its last 16 results (converted data, plot images)
- in memory, until newer renders push them out or its instance stops.
-
-
- usage telemetry. to see how the assistant is used and where it fails,
- it writes content-free lines: event types, data formats, size buckets, the plot type
- and library, the plot's language as a code (one of a dozen common languages, any
- other only as "other"), attempts, review defect ids, statuses, latencies and
- token counts. a line never holds your text, your data, your column names or your file
- names. the lines of one session share a salted hash of the session, whose salt lives
- only as long as the server instance, and they carry no user id — pseudonymous, not
- anonymous. they are kept for up to 180 days in a Google Cloud Logging bucket in the
- Netherlands (europe-west4) and read through BigQuery.
-
-
- attribution and rate limits. the assistant's own log lines,
- which debug it and enforce its limits, carry no content. in place of who you are they
- carry a random id your browser tab makes up, salted with a value that lives only as
- long as the server instance, so nothing ties it to you once that instance stops (for
- the site's admins, a keyed hash (HMAC) of their identity). the limits per network
- count your IP address the same way: salted, in memory only, never written down. the
- log lines sit in the server logs above, for 30 days. the API's request log (your
- IP address, 30 days) and these lines (salted ids, 30 days) could only be lined up by
- their timestamps, and the salted ids map to nothing else.
-
-
- feedback cases. only when you send feedback on a result or on the
- chat does the assistant store a case: the conversation, the profile of your data
- (column types and the first 5 rows as a sample), the plot's code and images if
- one was made and, only if you tick the "# include data.csv" box — it starts
- unticked — the whole data file. leaving it unticked leaves out that file only: what
- the conversation, the sample, the code and the images show of your data stays in the
- case. the form says so where you send it. the files go to a private bucket in the
- Netherlands (europe-west4) and are deleted after 180 days. a row with your reaction,
- your short message, a hash of your tab's random id and the technical details of
- the run (plot type, library, model) stays for the triage, and nothing deletes it on a
- timer — ask and yours is gone.
-
-
- use and warranty. the assistant is experimental and comes with no
- warranty: plots, code and answers can be wrong, and nobody checks them by hand, so
- check them before you rely on them. use it only with data you may share; don't
- use it to make unlawful content or to send it automated requests. daily limits apply
- per browser tab and per network, a misuse can pause you for the day, and the operator
- may pause or end the assistant at any time.
-
-
- the code you get. the code the assistant writes for you is yours to
- use, as is, like the catalogue's code under the MIT License. the plotting
- libraries keep their own licences: Highcharts is free for non-commercial use, and a
- commercial use needs a licence from Highsoft.
-
-
- transfers. Google Cloud and Cloudflare process what you send under
- their data processing terms, including the safeguards those terms set for transfers
- outside Switzerland and the EU.
-
+ where it runs
+
- legal basis. making the plot you asked for is the performance of your
- request (Art. 6(1)(b) GDPR); the telemetry and the rate limits rest on legitimate
- interest (Art. 6(1)(f) GDPR); a feedback case rests on your consent (Art. 6(1)(a)
- GDPR), given when you send it, which you can withdraw at any time by asking.
+ Google Cloud and Cloudflare process data under their data processing terms, including
+ their safeguards for transfers outside Switzerland and the EU.
- what we do not collect
+ how long it stays
+
+
+ legal bases (GDPR)
+
+
+ what we do not do
- • no user accounts or personal profiles
-
- • no name or email unless you type one — the optional contact field in the feedback
- widget is the only place that asks
+ • no cookies for visitors; the browser's storage keeps UI settings, your plot
+ votes, a release number and the random ids named here. admins sign in through
+ Cloudflare Access, which sets its own login cookie
- • no cookies for visitors (localStorage holds UI preferences — theme, image size — a
- cached release number, and, once you use the feedback widget, the random id above;
- sessionStorage holds the data assistant's random tab id). the site's admins
- sign in through Cloudflare Access, which sets its own login cookie
- • no ai training: nothing you send is used to train ai models.
- when you browse the catalogue, the ai in this project has already done its part: it
- writes the catalogue at build time and never sees a visitor's request. only the
- data assistant above sends what you give it to a model, under the terms described
- there
+ • no accounts, and no name or email unless you type one
+ • no ai training on what you send. the catalogue is written at
+ build time, so browsing never reaches a model
-
- contributors. if you suggest a plot type via GitHub, your GitHub
- username may be credited in the specification metadata. this is public information
- from your GitHub profile.
+
+ only if you use the data assistant (experimental)
-
- hosting & third parties
- the site, its database and its images run in the EU (Netherlands, europe-west4);
- Cloudflare's network is global, Plausible is a service of its own on its own EU
- servers, and the data assistant's models — used only when you use the assistant —
- are called in Vertex AI's EU region:
+ if you only browse the catalogue, none of it touches you.
-
-
-
- hosting
- Google Cloud Run (Netherlands)
-
-
- database
- Google Cloud SQL (Netherlands)
-
-
- storage
- Google Cloud Storage (Netherlands)
-
-
- cdn / ddos
- Cloudflare (global, EU data centers for EU visitors)
-
-
- analytics
- Plausible Analytics (EU, proxied)
-
-
- ai assistant
-
- Google Cloud Vertex AI, Claude models by Anthropic (EU) — only when you use the
- data assistant
-
-
-
-
+ your rights
- you can ask what is stored about you and have it corrected, erased or its use
- restricted, object to the processing on grounds relating to your particular situation,
- and complain to a supervisory authority. in practice that means a feedback entry, an
- assistant feedback case or a log line; a few lines to{' '}
+ you can ask what is stored about you and have it corrected, erased or restricted,
+ object to the processing on grounds relating to your particular situation, and
+ complain to a supervisory authority. a few lines to{' '}
admin@anyplot.ai
{' '}
- are enough, also to have an assistant feedback case deleted early, its files and its
- row.
+ are enough, also to have a feedback case deleted early.
@@ -420,10 +372,7 @@ export function LegalPage() {
transparency} />
-
- this project is open source and committed to full transparency about how it works and
- what it costs.
-
+ open source, down to what it costs to run.technology stack
diff --git a/changelog.d/legal-page-short.md b/changelog.d/legal-page-short.md
new file mode 100644
index 0000000000..5340b65ec7
--- /dev/null
+++ b/changelog.d/legal-page-short.md
@@ -0,0 +1,8 @@
+### Changed
+
+- **The legal page is half as long and states only the current state.** Its visible text drops from 1,805 to 983
+ words (the privacy policy from 1,546 to 762): what is processed, where it runs, how long each store keeps it, the
+ three legal bases and the data assistant's terms now sit in short fact tables, each fact said once. The notes on
+ log entries written before the move to europe-west4 and on older unsalted feedback hashes are gone; both conditions
+ ended on 2026-11-02. The crawler version (`/seo-proxy/legal`) mirrors the same facts in 344 words instead of 370,
+ now with all three legal bases, your rights, the telemetry retention and the no-training line.
diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py
index 96af8b793c..408d138752 100644
--- a/tests/unit/api/test_routers.py
+++ b/tests/unit/api/test_routers.py
@@ -1180,10 +1180,12 @@ def test_seo_legal_names_operator_and_privacy_facts(self, client: TestClient) ->
# BOTH retentions, not only the logs' — pinning just the 30 days is how
# that figure silently spreads over the store it is not true for.
assert "kept 30 days" in response.text
- assert "until it is deleted by hand" in response.text
- # The server logs sit in the EU since 2026-11-02 (the `_Default` sink's new bucket).
+ assert "kept until you ask to delete it" in response.text
+ # The server logs sit in the EU (the `_Default` sink's europe-west4 bucket).
assert "kept 30 days in Google Cloud Logging in the Netherlands (europe-west4)" in response.text
- assert "written before the move on 2 November 2026" in response.text
+ # The page states the current state only: no notes on what older entries still carry.
+ assert "before the move" not in response.text
+ assert "unsalted" not in response.text
def test_seo_legal_mirrors_the_data_assistant_section(self, client: TestClient) -> None:
"""The crawler body carries the assistant's facts the human page states (LegalPage.tsx `#assistant`)."""
@@ -1201,6 +1203,13 @@ def test_seo_legal_mirrors_the_data_assistant_section(self, client: TestClient)
"transfers outside Switzerland and the EU",
"cleared after ten minutes",
"consent (Art. 6(1)(a) GDPR)",
+ "your request (Art. 6(1)(b) GDPR)",
+ "legitimate interest (Art. 6(1)(f) GDPR)",
+ "Telemetry: up to 180 days",
+ "cleared after an hour idle",
+ "never used for training",
+ "the Cloudflare Access login cookie",
+ "a complaint to a supervisory authority",
):
assert fact in response.text, fact
From 778cf31ea96d11463f5b842039723a7089bd6c3b Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Mon, 2 Nov 2026 22:51:16 +0100
Subject: [PATCH 2/6] docs(legal): apply the pre-PR review to the shortened
legal page
- The crawler body's assistant bullet names the triage row's retention
next to the 180-day files, and says the input is processed under Google
Cloud's terms rather than "never used for training".
- "# include data.csv" stays on one line through a nowrap span instead of
invisible non-breaking spaces in the source.
- The site-wide facts move into the site-wide bot-page test.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
---
api/routers/seo.py | 7 ++++---
app/src/pages/LegalPage.tsx | 12 +++++++++++-
changelog.d/legal-page-short.md | 2 +-
tests/unit/api/test_routers.py | 17 +++++++++++------
4 files changed, 27 insertions(+), 11 deletions(-)
diff --git a/api/routers/seo.py b/api/routers/seo.py
index 5cba9f6ad9..798cda0a4c 100644
--- a/api/routers/seo.py
+++ b/api/routers/seo.py
@@ -1228,13 +1228,14 @@ async def seo_libraries():
"
"
"
Only if you use it: open to everyone as an experimental feature, and the operator may "
"pause it at any time. No warranty: results can be wrong, and nobody checks them by hand.
"
- "
Input goes to Claude by Anthropic via Google Cloud Vertex AI in the EU, never used for "
- "training.
"
+ "
Input goes to Claude by Anthropic via Google Cloud Vertex AI in the EU, under Google "
+ "Cloud's data processing terms; it is not used for training.
"
"
Sessions: server memory only, cleared after an hour idle.
"
"
Logs and telemetry hold no content; ids and IP addresses are salted with a value that lives "
"as long as the server instance. Telemetry: up to 180 days.
"
"
Stored only with feedback: the conversation, plots, code, a 5-row sample, and the data file "
- "only if you tick the box (it starts unticked); private bucket, 180 days.
"
+ "only if you tick the box (it starts unticked); private bucket, 180 days. The triage row stays "
+ "until you ask to delete it."
"
Its code is yours to use, as is; Highcharts is free for non-commercial use, and a commercial "
"use needs a licence from Highsoft.
"
"
"
diff --git a/app/src/pages/LegalPage.tsx b/app/src/pages/LegalPage.tsx
index 80fe053995..cebc97a66f 100644
--- a/app/src/pages/LegalPage.tsx
+++ b/app/src/pages/LegalPage.tsx
@@ -341,7 +341,17 @@ export function LegalPage() {
],
[
'feedback',
- 'kept only when you send feedback: the conversation, a profile of your data with its first 5 rows, the plot\'s code and images, and the whole data file only if you tick "# include data.csv". it starts unticked, which leaves out that file only. files go to a private bucket in the Netherlands; a triage row keeps your reaction, message, a hash of your tab id and the run\'s details.',
+ <>
+ kept only when you send feedback: the conversation, a profile of your data with
+ its first 5 rows, the plot's code and images, and the whole data file only
+ if you tick{' '}
+
+ "# include data.csv"
+
+ . it starts unticked, which leaves out that file only. files go to a private
+ bucket in the Netherlands; a triage row keeps your reaction, message, a hash of
+ your tab id and the run's details.
+ >,
],
[
'limits',
diff --git a/changelog.d/legal-page-short.md b/changelog.d/legal-page-short.md
index 5340b65ec7..712f746766 100644
--- a/changelog.d/legal-page-short.md
+++ b/changelog.d/legal-page-short.md
@@ -4,5 +4,5 @@
words (the privacy policy from 1,546 to 762): what is processed, where it runs, how long each store keeps it, the
three legal bases and the data assistant's terms now sit in short fact tables, each fact said once. The notes on
log entries written before the move to europe-west4 and on older unsalted feedback hashes are gone; both conditions
- ended on 2026-11-02. The crawler version (`/seo-proxy/legal`) mirrors the same facts in 344 words instead of 370,
+ ended on 2026-11-02. The crawler version (`/seo-proxy/legal`) mirrors the same facts in 362 words instead of 370,
now with all three legal bases, your rights, the telemetry retention and the no-training line.
diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py
index 408d138752..83c4e0c130 100644
--- a/tests/unit/api/test_routers.py
+++ b/tests/unit/api/test_routers.py
@@ -1186,6 +1186,15 @@ def test_seo_legal_names_operator_and_privacy_facts(self, client: TestClient) ->
# The page states the current state only: no notes on what older entries still carry.
assert "before the move" not in response.text
assert "unsalted" not in response.text
+ # The site-wide facts the human page states once each.
+ for fact in (
+ "legitimate interest (Art. 6(1)(f) GDPR)",
+ "the Cloudflare Access login cookie",
+ "a complaint to a supervisory authority",
+ "transfers outside Switzerland and the EU",
+ "cleared after ten minutes",
+ ):
+ assert fact in response.text, fact
def test_seo_legal_mirrors_the_data_assistant_section(self, client: TestClient) -> None:
"""The crawler body carries the assistant's facts the human page states (LegalPage.tsx `#assistant`)."""
@@ -1200,16 +1209,12 @@ def test_seo_legal_mirrors_the_data_assistant_section(self, client: TestClient)
"it starts unticked",
"180 days",
"a licence from Highsoft",
- "transfers outside Switzerland and the EU",
- "cleared after ten minutes",
"consent (Art. 6(1)(a) GDPR)",
"your request (Art. 6(1)(b) GDPR)",
- "legitimate interest (Art. 6(1)(f) GDPR)",
"Telemetry: up to 180 days",
"cleared after an hour idle",
- "never used for training",
- "the Cloudflare Access login cookie",
- "a complaint to a supervisory authority",
+ "it is not used for training",
+ "The triage row stays until you ask to delete it",
):
assert fact in response.text, fact
From 2d2200da19172a8d975d929cd43ab842d799f7bd Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Mon, 2 Nov 2026 22:52:20 +0100
Subject: [PATCH 3/6] docs(changelog): reference #12538 in the legal page
fragment
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
---
changelog.d/legal-page-short.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/changelog.d/legal-page-short.md b/changelog.d/legal-page-short.md
index 712f746766..7e44d079ac 100644
--- a/changelog.d/legal-page-short.md
+++ b/changelog.d/legal-page-short.md
@@ -5,4 +5,4 @@
three legal bases and the data assistant's terms now sit in short fact tables, each fact said once. The notes on
log entries written before the move to europe-west4 and on older unsalted feedback hashes are gone; both conditions
ended on 2026-11-02. The crawler version (`/seo-proxy/legal`) mirrors the same facts in 362 words instead of 370,
- now with all three legal bases, your rights, the telemetry retention and the no-training line.
+ now with all three legal bases, your rights, the telemetry retention and the no-training line (#12538).
From e7533b0ad576391b6664c834da3af99e964eb9f9 Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Mon, 2 Nov 2026 23:04:16 +0100
Subject: [PATCH 4/6] docs(legal): row headers in the fact tables, feedback
fields on the bot page
- Each fact table's label cell is a row header (th scope="row"), so a
screen reader announces "feedback IP hash, ten minutes" instead of an
unlabeled value.
- The crawler page's feedback bullet names the stored fields (message,
reaction, optional contact, page, window size, user-agent, random id),
as the human page does.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
---
api/routers/seo.py | 6 ++++--
app/src/pages/LegalPage.test.tsx | 2 ++
app/src/pages/LegalPage.tsx | 6 ++++--
changelog.d/legal-page-short.md | 9 +++++----
tests/unit/api/test_routers.py | 4 +++-
5 files changed, 18 insertions(+), 9 deletions(-)
diff --git a/api/routers/seo.py b/api/routers/seo.py
index 798cda0a4c..4bae449dc7 100644
--- a/api/routers/seo.py
+++ b/api/routers/seo.py
@@ -1212,8 +1212,10 @@ async def seo_libraries():
"
Hosting: Google Cloud Run, SQL and Storage in the Netherlands (europe-west4), Cloudflare at "
"the edge. Server logs: kept 30 days in Google Cloud Logging in the Netherlands "
"(europe-west4).
"
- "
Feedback: read only by the operator, kept until you ask to delete it; the salted IP hash "
- "against spam is cleared after ten minutes.
"
+ "
Feedback: your message and reaction, the optional contact field, the page, window size, "
+ "user-agent and a random id that ties your own submissions together; read only by the operator, "
+ "kept until you ask to delete it. The salted IP hash against spam is cleared after ten "
+ "minutes.
"
"
No cookies for visitors (admins: the Cloudflare Access login cookie); no AI training on "
"what you send.
"
"
Legal bases: legitimate interest (Art. 6(1)(f) GDPR) for the site and the assistant's "
diff --git a/app/src/pages/LegalPage.test.tsx b/app/src/pages/LegalPage.test.tsx
index 14f8b5e094..d19715ca90 100644
--- a/app/src/pages/LegalPage.test.tsx
+++ b/app/src/pages/LegalPage.test.tsx
@@ -78,6 +78,8 @@ describe('LegalPage', () => {
screen.getByRole('row', { name: new RegExp(`^${store}\\s*${kept}$`) })
).toBeInTheDocument();
}
+ // The label is the row's header, so a screen reader announces "feedback IP hash, ten minutes".
+ expect(screen.getByRole('rowheader', { name: 'feedback IP hash' })).toBeInTheDocument();
// Google Cloud, its logs included, runs in the EU region.
expect(
screen.getByText(
diff --git a/app/src/pages/LegalPage.tsx b/app/src/pages/LegalPage.tsx
index cebc97a66f..b6b560eae7 100644
--- a/app/src/pages/LegalPage.tsx
+++ b/app/src/pages/LegalPage.tsx
@@ -30,7 +30,7 @@ const firstColStyle = {
const AVATAR_URL = 'https://storage.googleapis.com/anyplot-static/images/markusneusinger.webp';
-/** A two-column fact table: a short label, then the fact. */
+/** A two-column fact table: a short label as the row header, then the fact. */
function FactTable({ rows }: { rows: [string, ReactNode][] }) {
return (
{rows.map(([label, fact]) => (
- {label}
+
+ {label}
+ {fact}
))}
diff --git a/changelog.d/legal-page-short.md b/changelog.d/legal-page-short.md
index 7e44d079ac..8aca9c1cb1 100644
--- a/changelog.d/legal-page-short.md
+++ b/changelog.d/legal-page-short.md
@@ -2,7 +2,8 @@
- **The legal page is half as long and states only the current state.** Its visible text drops from 1,805 to 983
words (the privacy policy from 1,546 to 762): what is processed, where it runs, how long each store keeps it, the
- three legal bases and the data assistant's terms now sit in short fact tables, each fact said once. The notes on
- log entries written before the move to europe-west4 and on older unsalted feedback hashes are gone; both conditions
- ended on 2026-11-02. The crawler version (`/seo-proxy/legal`) mirrors the same facts in 362 words instead of 370,
- now with all three legal bases, your rights, the telemetry retention and the no-training line (#12538).
+ three legal bases and the data assistant's terms now sit in short fact tables with row headers, each fact said
+ once. The notes on log entries written before the move to europe-west4 and on older unsalted feedback hashes are
+ gone; both conditions ended on 2026-11-02. The crawler version (`/seo-proxy/legal`, 385 words, 370 before) drops
+ the same history and now carries every fact the page states: the stored feedback fields, all three legal bases,
+ your rights, the telemetry retention and the no-training line (#12538).
diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py
index 83c4e0c130..ea65b2aaf4 100644
--- a/tests/unit/api/test_routers.py
+++ b/tests/unit/api/test_routers.py
@@ -1186,8 +1186,10 @@ def test_seo_legal_names_operator_and_privacy_facts(self, client: TestClient) ->
# The page states the current state only: no notes on what older entries still carry.
assert "before the move" not in response.text
assert "unsalted" not in response.text
- # The site-wide facts the human page states once each.
+ # The site-wide facts the human page states once each, the stored feedback fields included.
for fact in (
+ "a random id that ties your own submissions together",
+ "the optional contact field",
"legitimate interest (Art. 6(1)(f) GDPR)",
"the Cloudflare Access login cookie",
"a complaint to a supervisory authority",
From 602c585d9c0349519a553c65c46ecc4d84c9810d Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Mon, 2 Nov 2026 23:36:57 +0100
Subject: [PATCH 5/6] docs(legal): full stack and running costs, anchor
landing, cleared-hash notes
- Transparency (owner's decisions): eight grouped stack rows, each claim
checked against the repo; only the services the repo pins to
europe-west4 carry the region. The cost table shows October 2026 as
paid after commitments and credits (about $60 a month), with the note
on what it leaves out. The bot page mirrors one sentence.
- A fresh load of /legal#assistant scrolls to the section once the lazy
route renders; a 56 px scroll margin keeps every anchored section below
the sticky masthead (42 to 44 px).
- The fact tables style the label with :first-child: since the label is
a
, :first-of-type also matched the value cell and split the table
in half.
- The comments and docs that promised a migration for the legacy
unsalted feedback IP hashes say they were cleared once by hand on
2026-11-02 after a snapshot; the repository test keeps its behaviour.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
---
api/routers/feedback.py | 4 +-
api/routers/seo.py | 2 +-
app/src/pages/LegalPage.test.tsx | 78 ++++-
app/src/pages/LegalPage.tsx | 394 +++++++++----------------
changelog.d/legal-page-short.md | 19 +-
core/database/models.py | 4 +-
core/database/repositories.py | 5 +-
docs/concepts/agent-network.md | 2 +-
docs/reference/database.md | 4 +-
tests/integration/test_repositories.py | 5 +-
tests/unit/api/test_routers.py | 1 +
11 files changed, 240 insertions(+), 278 deletions(-)
diff --git a/api/routers/feedback.py b/api/routers/feedback.py
index 3c3a177e11..bd494c781d 100644
--- a/api/routers/feedback.py
+++ b/api/routers/feedback.py
@@ -48,8 +48,8 @@
IP_HASH_PREFIX = "hmac:"
-"""Marks a salted `ip_hash` (`_hash_ip`). Rows written before it carry an unsalted SHA-256 of
-the address, which a migration has to clear: it is left alone here."""
+"""Marks a salted `ip_hash` (`_hash_ip`). The unsalted SHA-256 hashes that rows written before it
+carried were cleared once by hand on 2026-11-02 after a snapshot; no migration is needed."""
_IP_SALT = secrets.token_bytes(32)
"""This process's salt for `_hash_ip`. Never logged and never configured, so it dies with the
process, and with it every way to tell which address a stored hash came from."""
diff --git a/api/routers/seo.py b/api/routers/seo.py
index 4bae449dc7..2f6e750ec6 100644
--- a/api/routers/seo.py
+++ b/api/routers/seo.py
@@ -1244,7 +1244,7 @@ async def seo_libraries():
"
Transparency
"
"
The whole stack — specs, pipeline, API and frontend — is open source at "
'github.com/MarkusNeusinger/anyplot; '
- "the catalogue content is MIT-licensed.
"
+ "the catalogue content is MIT-licensed. Running costs about $60 a month (October 2026)."
)
diff --git a/app/src/pages/LegalPage.test.tsx b/app/src/pages/LegalPage.test.tsx
index d19715ca90..6227d95996 100644
--- a/app/src/pages/LegalPage.test.tsx
+++ b/app/src/pages/LegalPage.test.tsx
@@ -1,8 +1,12 @@
-import { fireEvent } from '@testing-library/react';
+import { fireEvent, render as rtlRender } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
+import { ThemeProvider } from '@mui/material/styles';
+
import { LegalPage } from 'src/pages/LegalPage';
import { render, screen } from 'src/test-utils';
+import { theme } from 'src/theme';
const trackEvent = vi.fn();
vi.mock('src/hooks', () => ({
@@ -209,6 +213,53 @@ describe('LegalPage', () => {
expect(screen.getByRole('link', { name: 'React' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /FastAPI/ })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /PostgreSQL/ })).toBeInTheDocument();
+ // Eight grouped rows, each label a row header.
+ for (const group of [
+ 'site',
+ 'api',
+ 'catalogue pipeline',
+ 'data assistant',
+ 'google cloud',
+ 'edge & analytics',
+ 'reviews',
+ 'editor, typography',
+ ]) {
+ expect(screen.getAllByRole('rowheader', { name: group }).length).toBeGreaterThan(0);
+ }
+ expect(
+ screen.getByRole('row', {
+ name: /^data assistant\s*Google ADK, Claude Haiku 5\.5 via Vertex AI \(EU\)/,
+ })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/15\s+plotting libraries in Python, R, Julia and JavaScript/)
+ ).toBeInTheDocument();
+ // Only the services the repository pins to europe-west4 carry the region.
+ expect(
+ screen.getByText(
+ /Cloud Build and Artifact Registry in the\s+Netherlands \(europe-west4\), plus Secret Manager and Cloud Monitoring/
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('opens a fresh load of /legal#assistant at the assistant section', () => {
+ // The route is lazy, so the browser's own jump to the fragment finds nothing; the page scrolls itself.
+ const original = Element.prototype.scrollIntoView;
+ const scrollIntoView = vi.fn();
+ Element.prototype.scrollIntoView = scrollIntoView;
+ try {
+ rtlRender(
+
+
+
+
+
+ );
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
+ expect(scrollIntoView.mock.contexts[0]).toHaveAttribute('id', 'assistant');
+ } finally {
+ Element.prototype.scrollIntoView = original;
+ }
});
// The repository vendors JetBrains Mono (agents/anyplot/render/fonts/) under
@@ -233,10 +284,31 @@ describe('LegalPage', () => {
).toBeInTheDocument();
});
- it('renders hosting costs', () => {
+ it('renders the running costs of one named month, with what they leave out', () => {
render();
- expect(screen.getByText('~$34/month')).toBeInTheDocument();
+ expect(screen.getByText('monthly running costs (October 2026)')).toBeInTheDocument();
+ const costs: [string, string][] = [
+ ['hosting: Cloud Run \\(site, api, data assistant, renderer\\)', '~\\$10'],
+ ['database: Cloud SQL', '~\\$25'],
+ ['data assistant model: Claude Haiku via Vertex AI', '~\\$15'],
+ ['storage, logs and builds', '~\\$1'],
+ ['domain \\(\\.ai\\)', '~\\$8'],
+ ['total', '~\\$60/month'],
+ ];
+ for (const [item, cost] of costs) {
+ expect(
+ screen.getByRole('row', { name: new RegExp(`^${item}\\s*${cost}$`) })
+ ).toBeInTheDocument();
+ }
+ // Paid after discounts, so the note says which ones, and that the model cost moves with use.
+ expect(
+ screen.getByText(/after three-year commitments and promotional credits/)
+ ).toBeInTheDocument();
+ expect(screen.getByText(/the assistant.s model cost\s+grows with use/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/development tools and subscriptions are not included/)
+ ).toBeInTheDocument();
});
it('renders other projects links without noreferrer', () => {
diff --git a/app/src/pages/LegalPage.tsx b/app/src/pages/LegalPage.tsx
index b6b560eae7..1b9c94590c 100644
--- a/app/src/pages/LegalPage.tsx
+++ b/app/src/pages/LegalPage.tsx
@@ -1,5 +1,7 @@
import { type ReactNode, useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Table from '@mui/material/Table';
@@ -20,21 +22,40 @@ import {
textStyle,
} from 'src/theme';
-const firstColStyle = {
- '& .MuiTableCell-root:first-of-type': {
- fontWeight: 500,
- color: 'var(--ink-soft)',
- width: '25%',
- },
-};
-
const AVATAR_URL = 'https://storage.googleapis.com/anyplot-static/images/markusneusinger.webp';
+/** Where a linked section lands: below the sticky masthead (42 to 44 px), with a little air. */
+const ANCHOR_SX = { scrollMarginTop: '56px' } as const;
+
+/** An external link in the page's prose style, opened in a new tab. */
+function Ext({ href, children }: { href: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
/** A two-column fact table: a short label as the row header, then the fact. */
-function FactTable({ rows }: { rows: [string, ReactNode][] }) {
+function FactTable({
+ rows,
+ labelWidth = '25%',
+}: {
+ rows: [string, ReactNode][];
+ labelWidth?: string;
+}) {
return (
, so the value
is a first of its type too.
+ '& .MuiTableCell-root:first-child': {
+ fontWeight: 500,
+ color: 'var(--ink-soft)',
+ width: labelWidth,
+ },
+ }}
>
{rows.map(([label, fact]) => (
@@ -52,11 +73,20 @@ function FactTable({ rows }: { rows: [string, ReactNode][] }) {
export function LegalPage() {
const { trackPageview, trackEvent } = useAnalytics();
+ const { hash } = useLocation();
useEffect(() => {
trackPageview('/legal');
}, [trackPageview]);
+ // The route loads lazily, after the browser has looked for the fragment and not found it, so a
+ // fresh load of /legal#assistant (the chat's notice opens one in a new tab) would stay at the top.
+ // Scroll to it once the page is there; ANCHOR_SX keeps it clear of the sticky masthead.
+ useEffect(() => {
+ if (!hash) return;
+ document.getElementById(hash.slice(1))?.scrollIntoView?.();
+ }, [hash]);
+
const operatorContactBlock = (
<>
operator
@@ -168,7 +198,7 @@ export function LegalPage() {
{/* Legal Notice */}
-
+ legal notice} />
@@ -199,7 +229,7 @@ export function LegalPage() {
{/* Privacy Policy */}
-
+ privacy policy} />
@@ -313,7 +343,7 @@ export function LegalPage() {
build time, so browsing never reaches a model
-
+
only if you use the data assistant (experimental)
@@ -380,229 +410,87 @@ export function LegalPage() {
{/* Transparency */}
-
+ transparency} />
open source, down to what it costs to run.technology stack
-
+ monthly running costs (October 2026)
+ ~$60/month],
+ ]}
+ />
- direct hosting costs only. subscriptions (GitHub Pro, Plausible, Claude MAX, PyCharm,
- etc.) are shared across projects. all costs are currently covered privately.
+ after three-year commitments and promotional credits; the assistant's model cost
+ grows with use. development tools and subscriptions are not included. all costs are
+ covered privately.
diff --git a/changelog.d/legal-page-short.md b/changelog.d/legal-page-short.md
index 8aca9c1cb1..807633596e 100644
--- a/changelog.d/legal-page-short.md
+++ b/changelog.d/legal-page-short.md
@@ -1,9 +1,22 @@
### Changed
-- **The legal page is half as long and states only the current state.** Its visible text drops from 1,805 to 983
+- **The legal page is shorter and states only the current state.** Its visible text drops from 1,805 to 1,059
words (the privacy policy from 1,546 to 762): what is processed, where it runs, how long each store keeps it, the
three legal bases and the data assistant's terms now sit in short fact tables with row headers, each fact said
once. The notes on log entries written before the move to europe-west4 and on older unsalted feedback hashes are
- gone; both conditions ended on 2026-11-02. The crawler version (`/seo-proxy/legal`, 385 words, 370 before) drops
+ gone; both conditions ended on 2026-11-02. The crawler version (`/seo-proxy/legal`, 393 words, 370 before) drops
the same history and now carries every fact the page states: the stored feedback fields, all three legal bases,
- your rights, the telemetry retention and the no-training line (#12538).
+ your rights, the telemetry retention, the no-training line and the running costs (#12538).
+- **The transparency section lists the whole stack and what running the site costs.** Eight grouped rows (site,
+ api, catalogue pipeline, data assistant, Google Cloud, edge and analytics, reviews, editor and typography) replace
+ the old list, which named neither the data assistant nor most of the Google Cloud services. The cost table shows
+ October 2026 as paid after commitments and credits, about $60 a month with the assistant's model, in place of a
+ $34 estimate of hosting alone (#12538).
+
+### Fixed
+
+- **A link to a section of the legal page lands on its heading.** The route loads lazily, so a fresh load of
+ `/legal#assistant`, which the chat's notice opens in a new tab, stayed at the top of the page; it now scrolls to
+ the section once the page renders, below the sticky masthead. The code and docs that still promised a migration
+ for the old unsalted feedback IP hashes now say they were cleared once by hand on 2026-11-02 after a snapshot
+ (#12538).
diff --git a/core/database/models.py b/core/database/models.py
index 530c60fbe0..5a1b01e878 100644
--- a/core/database/models.py
+++ b/core/database/models.py
@@ -262,8 +262,8 @@ class Feedback(Base):
viewport: Mapped[str | None] = mapped_column(String(20), nullable=True)
session_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# "hmac:" + a per-process salted HMAC of the client IP (api/routers/feedback.py `_hash_ip`), only for the
- # rate limit and the duplicate check, cleared once their 10-minute window has passed; rows from before
- # 2026-11-02 still hold an unsalted SHA-256 until a migration clears them.
+ # rate limit and the duplicate check, cleared once their 10-minute window has passed. The unsalted SHA-256
+ # hashes of older rows were cleared once by hand on 2026-11-02 after a snapshot; no migration is needed.
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Triage state — see FEEDBACK_STATUSES above. Updated from /debug.
diff --git a/core/database/repositories.py b/core/database/repositories.py
index 8e616620ca..4dbb657482 100644
--- a/core/database/repositories.py
+++ b/core/database/repositories.py
@@ -439,8 +439,9 @@ async def clear_ip_hashes(self, prefix: str, before: datetime) -> int:
The feedback router stores a per-process salted hash (`api/routers/feedback.py`
`_hash_ip`) only for its rate-limit and duplicate windows and clears it here once
- they have passed. `prefix` keeps the statement to the hashes of that scheme: the
- unsalted hashes of older rows are left for a reviewed migration.
+ they have passed. `prefix` keeps the statement to the hashes of that scheme; the
+ unsalted hashes of older rows were cleared once by hand on 2026-11-02 after a
+ snapshot, so no migration is needed.
`before` must be tz-naive UTC, like `feedback.created_at`."""
result = await self.session.execute(
update(Feedback)
diff --git a/docs/concepts/agent-network.md b/docs/concepts/agent-network.md
index 0c14d853e4..7b1edfe2d5 100644
--- a/docs/concepts/agent-network.md
+++ b/docs/concepts/agent-network.md
@@ -692,7 +692,7 @@ Defaults apply until the owner decides otherwise.
- Feedback-case retention and data inclusion: 180 days in the private bucket; `include_data` on by default for admins and off by default with explicit consent for real users.
- anyplot-api on two instances for the public lane (launch package of 2026-11-02): not yet, `_MAX_INSTANCES` stays 1. The API's in-memory response cache (`api/cache.py`, refreshed after 1 h) is cleared by one `POST /debug/cache/invalidate` per sync, which reaches one instance only, so a second instance would serve the catalogue as it was before a sync for up to an hour; and every scale-out is a cold start a live request pays for (the measurement in `api/cloudbuild.yaml`). The public lane's own stream cap keeps the catalogue's share on one instance instead. Nothing else relies on one instance: the BFF keeps no session state, the identity salting lives in the agents service, and MCP is stateless.
- Trust in `CF-Connecting-IP` for the per-IP caps: a caller who bypasses Cloudflare for the direct `run.app` URL can forge it, and so pick a fresh network bucket per request, until the origin gate is armed (`ORIGIN_SECRET`, `api/origin_gate.py`). Arming the gate before the public launch closes it; the global day budget bounds the damage meanwhile.
-- The unsalted `ip_hash` of feedback rows from before 2026-11-02: the router stores a per-process salted hash since then and clears it after its 10-minute window, but older rows keep a plain SHA-256 of the client address, which trying every IPv4 address undoes. Clearing them is a data-rewriting migration that needs a snapshot and the owner's OK.
+- The unsalted `ip_hash` of feedback rows from before 2026-11-02: closed. The router stores a per-process salted hash since then and clears it after its 10-minute window; the older rows' plain SHA-256 of the client address, which trying every IPv4 address undoes, was cleared once by hand on 2026-11-02 after a snapshot (all 40 rows), so no migration is needed.
- The sandbox's private `/tmp` and memory: spike S2 counts each sandbox's `/tmp` tmpfs (about 1.9 GiB, no flag to shrink it) as instance memory, which on paper puts even one sandbox (1 GiB address space, 1.9 GiB `/tmp`, the 512 MiB volume) above the 3 GiB budget of a 4 GiB instance. Measured on 2026-10-10 through the deployed renderer (a throwaway service, 2 vCPU, 4 GiB, one sandbox at a time): code that writes 40 MiB files into `/tmp` until it is full ends after 4.6 s with `OSError: [Errno 28] No space left on device`, the instance stays up with 3,328 MiB available afterwards (3,451 before), and the memory watchdog never has to fire. Decided: a 4 GiB instance with one sandbox at a time, without `RENDERER_BIND_TMP` (which stays available, untested) and without an 8 GiB instance; the validator (no file writes in user code), the serial slot, the `MemAvailable` floor before a render (1,024 MiB) and the watchdog's floor during one (512 MiB, reason `memory`) stay as the controls. Re-measure before a second render slot.
- How a developer's `adk web` authenticates to the deployed renderer: `AGENT_RENDER_TOKEN` from `gcloud auth print-identity-token` is the path spike S proved against Cloud Run IAM; the token minted from a user's Application Default Credentials carries the ADC OAuth client id as `aud`, and whether Cloud Run IAM accepts it is not yet verified. Either way, the token's client id joins `RENDERER_AUDIENCES` (both client ids are in the deployed service's `_AUDIENCES`) and the owner's email `RENDERER_ALLOWED_CALLERS`. Checked on 2026-10-11 against the deployed renderer: the gcloud CLI token renders (the harness and the local stack use it); the ADC token path is still unverified.
- Cold starts of the renderer: with `--min-instances=0`, the first render after an idle period meets a cold instance, and spike S saw the front end answer 500, then 429, and a 200 only about 17 s after the first request. The `remote` backend retries such answers after 1, 3, 8 and 15 s within 30 s. The deployed renderer runs with `--min-instances=0` since 2026-10-11, and the harness rendered through it that day without a retry failure, but no render has been timed against a deliberately cold instance yet; that measurement decides whether the retries suffice or the renderer runs with `_MIN_INSTANCES=1` (the billing commitments probably absorb its cost).
diff --git a/docs/reference/database.md b/docs/reference/database.md
index b4e7d2c6e1..d5d85aacea 100644
--- a/docs/reference/database.md
+++ b/docs/reference/database.md
@@ -175,8 +175,8 @@ CREATE TABLE feedback
viewport VARCHAR(20),
session_id VARCHAR(64),
ip_hash VARCHAR(64), -- "hmac:" + a per-process salted HMAC of the client IP, for the rate limit;
- -- cleared once its 10-minute window has passed (rows from before
- -- 2026-11-02 still hold an unsalted SHA-256 until a migration clears them)
+ -- cleared once its 10-minute window has passed (the unsalted SHA-256
+ -- of older rows was cleared once by hand on 2026-11-02 after a snapshot)
status VARCHAR(20) NOT NULL, -- new, in_progress, done, wont_solve
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
diff --git a/tests/integration/test_repositories.py b/tests/integration/test_repositories.py
index ed50218a59..39f753f959 100644
--- a/tests/integration/test_repositories.py
+++ b/tests/integration/test_repositories.py
@@ -452,7 +452,10 @@ async def test_count_recent_by_ip_messages_only(self, test_session):
assert await repo.count_recent_by_ip("aaa", since, messages_only=True) == 1
async def test_clear_ip_hashes_clears_only_the_salted_ones_past_the_window(self, test_session):
- """The router's salted hashes outlive no window; the unsalted ones of older rows are left for a migration."""
+ """The router's salted hashes outlive no window; a hash of any other scheme is not this statement's to clear.
+
+ Production holds no unsalted hashes since the one-off clean-up of 2026-11-02; the legacy row here
+ pins that the statement stays scoped to its own prefix."""
from datetime import datetime, timedelta, timezone
repo = FeedbackRepository(test_session)
diff --git a/tests/unit/api/test_routers.py b/tests/unit/api/test_routers.py
index ea65b2aaf4..32a77ad4e2 100644
--- a/tests/unit/api/test_routers.py
+++ b/tests/unit/api/test_routers.py
@@ -1188,6 +1188,7 @@ def test_seo_legal_names_operator_and_privacy_facts(self, client: TestClient) ->
assert "unsalted" not in response.text
# The site-wide facts the human page states once each, the stored feedback fields included.
for fact in (
+ "Running costs about $60 a month (October 2026).",
"a random id that ties your own submissions together",
"the optional contact field",
"legitimate interest (Art. 6(1)(f) GDPR)",
From d16dac4907eb6a876a5d6d33b63e37c8d6a955a8 Mon Sep 17 00:00:00 2001
From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
Date: Mon, 2 Nov 2026 23:50:35 +0100
Subject: [PATCH 6/6] docs(legal): opportunistic IP-hash cleanup, crawler page
carries the rest
- The feedback IP hash is cleared by the next feedback after its ten
minutes (the router calls the cleanup only inside POST /feedback), and
its salt dies with the server instance; the page and the crawler body
say that instead of a flat "ten minutes".
- The crawler body adds the facts the human page states and it lacked:
Cloudflare's use of the IP address, no accounts and no name or email
unless typed, the GitHub username credit, and that browsing never
reaches a model.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01H7HSkGyea1k2v293XiAMo8
---
api/routers/seo.py | 14 ++++++++------
app/src/pages/LegalPage.test.tsx | 13 +++++++++----
app/src/pages/LegalPage.tsx | 5 ++++-
changelog.d/legal-page-short.md | 12 +++++++-----
tests/unit/api/test_routers.py | 6 +++++-
5 files changed, 33 insertions(+), 17 deletions(-)
diff --git a/api/routers/seo.py b/api/routers/seo.py
index 2f6e750ec6..2763563cbd 100644
--- a/api/routers/seo.py
+++ b/api/routers/seo.py
@@ -1210,14 +1210,16 @@ async def seo_libraries():
"
Analytics: Plausible Analytics (EU), through this domain; no cookies, no cross-site id, "
"no IP address stored.
"
"
Hosting: Google Cloud Run, SQL and Storage in the Netherlands (europe-west4), Cloudflare at "
- "the edge. Server logs: kept 30 days in Google Cloud Logging in the Netherlands "
- "(europe-west4).
"
+ "the edge, which uses your IP address to rate-limit, filter bots and absorb DDoS attacks. Server "
+ "logs: kept 30 days in Google Cloud Logging in the Netherlands (europe-west4)."
"
Feedback: your message and reaction, the optional contact field, the page, window size, "
"user-agent and a random id that ties your own submissions together; read only by the operator, "
- "kept until you ask to delete it. The salted IP hash against spam is cleared after ten "
- "minutes.
"
- "
No cookies for visitors (admins: the Cloudflare Access login cookie); no AI training on "
- "what you send.
"
+ "kept until you ask to delete it. The salted IP hash against spam is cleared by the next "
+ "feedback after ten minutes, and its salt dies with the server instance."
+ "
No cookies for visitors (admins: the Cloudflare Access login cookie), no accounts, and no "
+ "name or email unless you type one. Suggesting a plot type on GitHub may credit your public "
+ "username in its spec.
"
+ "
No AI training on what you send; browsing the catalogue never reaches a model.
"
"
Legal bases: legitimate interest (Art. 6(1)(f) GDPR) for the site and the assistant's "
"telemetry and limits; your request (Art. 6(1)(b) GDPR) for a plot; consent (Art. 6(1)(a) GDPR) "
"for a feedback case, withdrawable at any time.
"
diff --git a/app/src/pages/LegalPage.test.tsx b/app/src/pages/LegalPage.test.tsx
index 6227d95996..45987635a2 100644
--- a/app/src/pages/LegalPage.test.tsx
+++ b/app/src/pages/LegalPage.test.tsx
@@ -69,10 +69,15 @@ describe('LegalPage', () => {
render();
// One table row per store, each with its own figure: the 30 days belong to
- // the server logs only, and the feedback rows have no timer at all.
+ // the server logs only, and the feedback rows have no timer at all. The IP
+ // hash is cleared only when the next feedback arrives (api/routers/feedback.py
+ // `_clear_expired_ip_hashes`), so the row says so instead of a flat "ten minutes".
const retention: [string, string][] = [
['server logs', '30 days'],
- ['feedback IP hash', 'ten minutes'],
+ [
+ 'feedback IP hash',
+ 'cleared by the next feedback after ten minutes; its salt dies with the server instance',
+ ],
['assistant telemetry', 'up to 180 days'],
['feedback case files', '180 days'],
['feedback entries, case rows', 'until you ask to delete them'],
@@ -82,7 +87,7 @@ describe('LegalPage', () => {
screen.getByRole('row', { name: new RegExp(`^${store}\\s*${kept}$`) })
).toBeInTheDocument();
}
- // The label is the row's header, so a screen reader announces "feedback IP hash, ten minutes".
+ // The label is the row's header, so a screen reader announces it before the figure.
expect(screen.getByRole('rowheader', { name: 'feedback IP hash' })).toBeInTheDocument();
// Google Cloud, its logs included, runs in the EU region.
expect(
@@ -109,7 +114,7 @@ describe('LegalPage', () => {
expect(
screen.getByText(/a random id that ties your own submissions together/)
).toBeInTheDocument();
- // The feedback's IP hash is salted; its ten minutes sit in the retention table.
+ // The feedback's IP hash is salted; when it goes sits in the retention table.
expect(screen.getByText(/a salted hash of your IP address limits spam/)).toBeInTheDocument();
// Plausible's real property is cookieless and identifier-free, not that
// the script is ours — it is Plausible's, only served from our domain.
diff --git a/app/src/pages/LegalPage.tsx b/app/src/pages/LegalPage.tsx
index 1b9c94590c..af99995664 100644
--- a/app/src/pages/LegalPage.tsx
+++ b/app/src/pages/LegalPage.tsx
@@ -310,7 +310,10 @@ export function LegalPage() {
"the Cloudflare Access login cookie",
"a complaint to a supervisory authority",
"transfers outside Switzerland and the EU",
- "cleared after ten minutes",
+ "cleared by the next feedback after ten minutes, and its salt dies with the server instance",
+ "uses your IP address to rate-limit, filter bots and absorb DDoS attacks",
+ "no name or email unless you type one",
+ "may credit your public username",
+ "browsing the catalogue never reaches a model",
):
assert fact in response.text, fact