/account/privacy is one screen because the questions it answers are asked in
one sitting: what state is my account in, how can you reach me, what have I
agreed to, what do you actually hold, and how do I leave. Splitting those across
routes would make the last one — the one people arrive angry looking for — the
hardest to find. It is reached from the hub on /account/profile under
Personal data.
Two of those questions have an answer that is not “everything you asked for”.
A deletion request is a request and not a deletion, and an export leaves several
categories out on purpose. Both say so on the screen, in the document, and here,
because a privacy surface that overstates what it did is worse than one that does
less.
Account state and retention
The first card is the account’s own status — Active, Suspended, or Deleted — with the description for that state, followed by whichever of these instants are set: suspended on, deleted on, anonymised on, and records kept until. The dated instants are the point of the card. A screen that showed only the status would leave somebody with no way to tell whether “deleted” meant gone. Each optional instant is a JSONnull when it never happened rather than the
zero time, so “never suspended” and “suspended at the beginning of time” cannot
be confused.
A deletion recorded from the operator panel today sets the status without
writing a retention window, so Records kept until stays empty for a
customer in that state. The line appears when the window is set.
Contact channels
Under How we reach you, one row per address: the kind, the address itself, a verified or not-verified badge, and two switches spelling out what may be sent there — service messages, and offers and news. Below the list, a form to add one: choose Email, Phone, or Telegram, type the value, set the two switches, press Add address. Remove opens a confirmation dialog naming the value. Adding an address records an intention and nothing more. The panel cannot prove an address belongs to the person typing it, so the flags are a permission the delivery pipeline reads rather than a claim that the address is verified, and the form says so. How the value is stored. The address is sealed with AES-GCM under the installation’s data key, and the uniqueness constraint is on a keyed HMAC of the normalised value rather than a plain digest — so a stolen database cannot be tested for a known address by hashing candidates. The channel kind is bound in as additional authenticated data, so a value sealed as an email cannot be replayed as a phone number. A value that cannot be opened, typically after a key rotation, comes back empty rather than failing the whole list. Normalisation is not cosmetic. Email is lower-cased; a phone number loses spaces, hyphens, and parentheses; a Telegram username is lower-cased with any@
stripped. Without it, +7 900 000 00 00 and +79000000000 would be two channels
for one phone, because the constraint is on the MAC of the value.
Refusals.
The conflict answer says the address is unavailable and nothing else. Telling a
caller that an address belongs to another account turns the panel into an oracle
for whether a given person is a customer here, so the remedy is a support
handoff. The one exception reveals nothing: a row this customer already owns
is un-revoked and re-flagged instead.
The rate limit fails closed. If the limiter cannot be reached, the request is
refused, so an outage cannot silently remove the protection.
Removal revokes rather than deletes, so an address cannot be quietly moved
between accounts by removing it from one, and the consent history keeps pointing
at something that still exists. Adding a channel with offers enabled appends a
consent record; removing the last channel that had offers enabled appends a
withdrawal — writing one while another opted-in channel remains would misstate
what the customer chose. Every add and remove writes an audit row carrying the
kind and the marketing flag, never the value.
The preferences screen shows the same channels with
their flags, but never the addresses: that route does not select the ciphertext
at all.
The consent trail
“Am I signed up for offers?” is the first question. “When did I agree to that, and under which terms?” is the second, and an installation that can only answer the first cannot show a customer why an old choice still stands after the terms changed.consent_records is append-only. Each row carries the purpose, whether it was
granted or withdrawn, the terms version, the surface it came from, and the id of
the request that wrote it. The current answer for a purpose is simply the newest
row for it; nothing is edited in place, because a history that can be rewritten
is not evidence of anything.
The screen lists one row per purpose with an Agreed or Withdrawn badge
and its provenance line, and an expandable Full history with the dated trail
behind it, bounded to the 25 most recent decisions.
An unrecognised purpose renders its raw name with a generic hint rather than
being hidden.
The trail is read-only here. Marketing consent is changed from the switches on
the contact channels above it and from the marketing switch on
/account/preferences; those two paths stamp different provenance —
account-web-v0.10 / account_web from the contacts form, web-v0.10 /
customer_web from the preferences screen, and bot-v0.4 / telegram_bot from
the Telegram settings screen. A customer reading their own trail will therefore
see more than one terms version against the same purpose. Each row is still an
accurate record of the decision that produced it.
Exporting your data
The Download your data card lists what the export will contain and what it deliberately leaves out before the button is pressed, then produces the whole document synchronously and saves it to the device. It is synchronous rather than queued on purpose. A queued export needs a delivery channel, a retention window, and a link that authenticates on its own — three further disclosure decisions to answer a question that fits inside one response. Nothing is written to disk, nothing is emailed, and the document exists in that one response and nowhere else.What is in it
The document declares
version: "omniflow.account.export/1", so a customer who
kept an export from a year ago — and the operator reading it with them — can tell
which version’s field meanings apply.
What is left out, and why
The redaction list ships inside the document rather than only on the screen, because an export that quietly omits things reads as complete and a customer cannot ask about an absence they cannot see.
Internal operator notes on a support ticket are not in the redaction list because
they are not in the export path at all: they live in their own table and no
customer-facing query touches it. Operator identity is omitted from lifecycle
rows — naming the individual who suspended an account, in a document that account
holder receives, is a safety question rather than a transparency one.
Limits and refusals
- Three exports per hour, keyed on the customer id, because the thing being
bounded is how often one account’s data can be pulled out of the database.
Over the limit answers 429
rate_limited. The limiter fails closed. - Each list is capped at 1000 rows, and any section that hit the cap is named in the document and reported on the screen. A customer told their data is complete when it is not has been misinformed about the one thing the document exists to answer.
- With no
APP_DATA_ENCRYPTION_KEYconfigured, contact channels are still listed — that a channel of a given kind exists is itself a fact the customer is entitled to — but without their addresses, and a warning card says so before the download. - If the document’s declared redactions differ from what the screen previewed, the panel says so afterwards. That would mean the server changed its mind between the two calls, and it is exactly the case a customer should not have to discover by diffing a file against a page.
omniflow-personal-data-YYYY-MM-DD.json, and the object URL is
revoked immediately.
An audit row account.data.exported is written after assembly and before
hand-off, so a recorded export is always one that actually succeeded, and its
metadata carries row counts only — an audit row describing what was disclosed
must not become a second copy of it.
The export is a
POST even though it changes nothing the customer can see. A
GET would be cached, prefetched, logged in a proxy’s access line, and
reachable by navigation, and this is the most sensitive document the API
produces.Requesting deletion
1
Write a reason
Required, at most 400 characters, with a live counter. It is free text
rather than a menu, because a closed list would put the operator’s categories
in the customer’s mouth. The counter enforces the limit in the browser too,
so nobody is stopped by a rejected request after writing a paragraph.
2
Confirm
A dialog follows the button. Only then is
{"confirm": true, "reason": "…"}
sent; without the flag the route answers 422 confirmation_required.3
Read the pending panel
The section becomes a warning panel carrying the request date, the sentence
naming who carries it out, the customer’s own reason, and a Cancel the
request button behind its own dialog.
executedBy: "operator_retention_workflow" explicitly. The difference between
“requested” and “done” is the entire safety property of this route, and a screen
that does not say so invites a customer to believe their data is already gone.
Requesting deletion needs a sign-in from within the last 15 minutes; a stale
session answers 401 reauthentication_required and the panel shows the warning,
with a link back to sign-in, before the button is pressed rather than after.
Recent authentication is what stops a borrowed laptop from making the request —
it is not a licence to execute it, which is why the request is still only a
request. Cancelling needs no re-authentication, because cancelling only ever
prevents the irreversible thing.
Repeating the request changes nothing and would only add noise to the record an
operator reads before acting on it, which is why the second one is refused rather
than accepted quietly.
Cancelling does not erase the request. The lifecycle event stays, because
identity records are append-only: “this was asked for and then withdrawn” is a
different fact from “this was never asked for”, and an operator reviewing a later
request deserves to see both. Pending-ness is derived by comparing three instants
— the newest deletion request, the newest cancellation, and the newest deletion
or anonymisation — and a request is pending only when it is the most recent of
the three. A withdrawn or already-executed request keeps its timestamps but drops
its reason from the response: those words were written for a decision that is no
longer open, and repeating them on screen implies it still is.
What deletion does not remove
Deletion is carried out by an operator through the customer lifecycle controls, and it is not the same operation as anonymisation. Anonymisation is irreversible, runs only against an already-deleted account, and is what actually removes identifying data:- every identity is revoked and its provider subject replaced with
anonymized:<id>; - every contact channel’s ciphertext is nulled and its fingerprint re-derived from the row id, with both delivery flags cleared;
- the Remnawave mapping loses its Telegram id and its observed state;
- every support message body on the customer’s tickets becomes
[anonymized]; - the referral code is deleted.
What Omniflow never shows or stores
These are not omissions. Each one is a decision visible in the code, and each one costs something — which is why they are worth stating. No hardware identifiers in the device list./account/devices shows a
platform icon, the model the client reported about itself, and a date. The handle
used to remove a device is a 128-bit digest of the hardware id, and it is
resolved by re-hashing the current device list rather than by a stored mapping,
so the caller never supplies the identifier being acted on. There is no HWID and
no IP address on that screen: enough for a customer to recognise their own
laptop, not enough to build a movement log out of.
No IP addresses outside the customer’s own security screen. The session list
and the account event log at /account/security do carry an IP, deliberately —
an address the customer does not recognise is the entire reason that screen
exists. Neither is an export section, and network identifiers are a declared
redaction, so no IP reaches the export.
No subscription link anywhere it would linger. The dashboard response does
not carry it; it is fetched on demand only by the connection screen, because a
dashboard payload that always carried it would put a credential into every cache,
log, and history entry that touches the page. The QR code is generated in the
browser and rendered from a data URL rather than routed through an image endpoint,
which would send a credential-bearing payload to a server to be cached. Rotating
the link records a security event carrying the subscription’s label and nothing
else — putting the link into the customer’s own security log would defeat the
rotation it is recording. See connecting a device.
No free text in the customer’s security log. The event vocabulary is closed
and enforced by the database: signed_in, signed_out, signed_out_all,
session_revoked, magic_link_requested, identity_linked,
identity_unlinked, subscription_key_rotated, device_removed,
devices_removed_all. There is no column for an amount, a link, or another
party’s identifier. It is a separate table from the operator audit trail because
the audience is different, and deciding per row which fields a customer may see
would put a disclosure decision into a read path.
No forwardable Telegram messages. Every message the bot sends is dispatched
with Telegram’s content protection on and link previews disabled, so a screenshot
of a subscription link, an order, or a support conversation cannot be forwarded
or saved out of the chat. The bot-delivered sign-in link is protected by the same
rule, and its copy says so plainly: the link works once, expires in ten minutes,
and whoever opens it signs in as the customer.
No customer content in the operator group. Notices posted to the operator
Telegram group name the event, the amount class, and identifiers an operator can
look up. They never carry customer text, a subscription link, a token, or a
payment payload; payloads are redacted at enqueue and again at render against a
field whitelist. See operator notifications.
No indexing, no caching. The account panel sets noindex, nofollow at the
layout level, and every /v1/account response carries Cache-Control: no-store,
Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, a
default-src 'none' content security policy, and a permissions policy denying
camera, microphone, geolocation, and topic-based interest cohorts. The reason
named in the code is the same one as above: a customer response can contain a
subscription link, which is a credential.
Reference
Related
Notifications
Consent, quiet hours, the frequency cap, and the announcement inbox.
Customer web panel
Sign-in, sessions, and the rest of the account surface.
Connecting a device
Where a subscription link lives and how it is rotated.
Security posture
The operator’s view: credentials, sessions, secrets, and transport.