What is removed before publishing
Each dashboard reaches the gallery as two artifacts — a screenshot and a Data Studio export bundle — and both are rewritten before anything is published. The rule the whole design is built around is short:
An unredacted original is never publicly reachable.
That is enforced in the API rather than in the page. The gallery’s own pages are static files served to anyone who asks for them, so they can never be the thing deciding what is allowed.
The publish pipeline
Section titled “The publish pipeline”In sentences: the upload goes to private storage, where nothing public can reach it. The image is redacted by OCR proposing rectangles, a person reviewing them, and the browser flattening the result into a new file. The bundle is scrubbed and then audited by re-scanning what the scrubber just wrote — if the audit still finds a match, nothing is written to public storage at all. Only the flattened image and a clean bundle reach the public side.
The image: redaction is destructive
Section titled “The image: redaction is destructive”Every bar in a published screenshot covers pixels that were never written into the file. The published image is not the original with rectangles drawn over it — it is a new image, drawn to a canvas with opaque rectangles filled in and saved. There is nothing underneath a bar to uncover.
Bars are opaque, not blurred. Blur on small text is a weaker guarantee than it looks.
The detection step runs in the browser, and the OCR engine is served from the gallery’s own origin rather than a CDN — the unredacted image never leaves the machine it was opened on. Each recognised word is checked against the same pattern set the bundle scrubber uses, and a match becomes a proposed rectangle that a person accepts, moves, resizes or deletes. Running detection again only adds proposals; it never removes a bar placed by hand.
Publishing with no bars at all
Section titled “Publishing with no bars at all”Some screenshots genuinely have nothing to cover, so publishing with zero rectangles is allowed — but only after a second confirmation, recorded explicitly. To publish, a dashboard needs a flattened image and either at least one rectangle or that explicit record.
The rectangles are counted, not merely checked for: an editor run that covered nothing still saves an empty list, and a list existing is not evidence that anything was covered.
What stays private
Section titled “What stays private”- The original screenshot, under a storage prefix that the code serving public assets cannot construct a key into.
- The rectangle coordinates. They are kept only so a redaction can be reopened and adjusted, and are never used to render a public view.
- The scrub report (below).
The bundle: scrubbed once, on upload
Section titled “The bundle: scrubbed once, on upload”Redacting only the screenshot would publish the same data in plain YAML — the export carries tenant identifiers, owner identifiers, owner email addresses and hardcoded filter values. So the bundle is rewritten too.
Identity fields are deleted, not replaced
Section titled “Identity fields are deleted, not replaced”An import assigns ownership to whoever runs it, so there is nothing left for these to do. A pseudonymised owner id would be a lie the importer then has to notice.
tenant_ids · tenant_id · owner_ids · owners · created_by ·
changed_by · created_by_fk · changed_by_fk · last_saved_by · email ·
username · user_id · certified_by · certification_details
Filters on tenant-scoped columns are dropped
Section titled “Filters on tenant-scoped columns are dropped”Some columns hold tenant data whatever value they contain. A venue named “Home” is an ordinary English word that no shape-based rule can find, so these clauses are matched on the column name instead, and the whole clause is removed:
zoneName · venueName · venue · zone · ssid · networkName ·
wlanName · apName · apMac · apGroupName · apSerialNumber ·
clientMac · clientIp · userName · userId · deviceName · hostname ·
serialNumber · switchName · switchMac · tenantId · tenantName ·
accountName · customerName
Matching is case-insensitive, because bundles are inconsistent about zoneName
versus zonename.
Values with a shape are replaced
Section titled “Values with a shape are replaced”Remaining strings are checked against the same pattern set the screenshot editor uses. Each distinct real value maps to the same replacement every time, so a filter still matches the chart beside it.
| What is matched | Replaced with | Range |
|---|---|---|
| MAC address | 00:00:5e:00:53:xx | RFC 7042 documentation block |
| IPv4 address | 192.0.2.x | RFC 5737 TEST-NET-1 |
| IPv6 address | 2001:db8::x | RFC 3849 documentation prefix |
| Email address | user1@example.com | Reserved example domain |
| Hostname / FQDN | host-1.example.com | Reserved example domain |
| UUID / tenant id | 00000000000000000000000000000000 | — |
| Serial number | SERIAL000001 | — |
Replacements are shaped like what they replace rather than being
[REDACTED], for two reasons. A filter whose comparator became [REDACTED]
would import cleanly and then render nothing. And because they keep their shape,
the audit re-finds them all — which is how “scrubbed correctly” is told apart
from “still leaking”.
Two places that are easy to miss, and are not missed
Section titled “Two places that are easy to miss, and are not missed”- The nested copy. One chart field holds a JSON document inside a YAML string, carrying a second copy of the chart’s parameters and filters — tenant identifiers included. It is parsed, scrubbed by the same rules and written back. A scrubber that walked the YAML tree alone would publish that copy intact.
- The dialect. Files are re-serialised as YAML 1.1, which is what the
importer reads. A default YAML 1.2 writer leaves unquoted precisely what a 1.1
reader retypes: an exported timestamp comes back as a date rather than a
string, and the importer rejects the whole archive before reading a single
chart. The same gap turns
yes/no/on/offinto booleans and10:30:00into a number.
The audit refuses the write
Section titled “The audit refuses the write”The archive the scrubber just wrote is re-opened and scanned again over its raw text. Replacement values are recognised as replacements rather than flagged again. If anything else still matches a pattern, nothing is written to public storage at all — storing it and relying on a published check to keep it unreachable would put a leaking object in the public namespace with one query between it and the world.
The report, and what only a person can catch
Section titled “The report, and what only a person can catch”The report lists every removed field, every dropped filter with the value it held, every real-to-replacement pair, and any file that could not be inspected. It stays with the record and is never published.
It is read before the dashboard is, because some things no rule here can find: a venue name, a network name or a site label sitting in a chart title or a block of prose. Those have no shape, and the read is what catches them.
Why the boundary holds
Section titled “Why the boundary holds”| Mechanism | What it prevents |
|---|---|
| Two storage prefixes — originals private, published artifacts public | Nothing public shares a namespace with an original |
| Asset URLs take an id, a revision and one of three fixed filenames | There is no input that can address an object outside the public namespace |
Every public query bakes status = 'published' into its SQL | A route cannot forget to filter for published-ness |
| Only the revision a record currently points at is served | A superseded, less redacted revision cannot be reached by guessing the previous number |
| Public responses list what goes out rather than deleting what must not | A new column is private until someone decides otherwise |
That last row is why original keys, the scrub report and the rectangle coordinates are absent from every public response: they were never added to the list of things that go out.