Data
Data handling
Where your data lives, in which table, what leaves our server, how to get it back and how to make it go away. The privacy policy states the rights; this page describes the mechanisms, with column names, so that you can check rather than take our word for it.
1. Where the data lives
A dedicated server located in Germany, at Hetzner. PostgreSQL runs on that machine, not at a third-party database host, and the application runs on the same one.
- The whole product is in that database: readings (`readings`), status pages (`status_pages`, `status_services`, `status_daily_statuses`, `status_incidents`, `status_incident_updates`), subscribers (`status_subscribers`), keys (`api_keys`) and OAuth access tokens (`oauth_access_tokens`).
- The internal machinery is there too, because it is configured onto the database rather than onto a separate service: the cache (`CACHE_STORE=database`) and the job queue (`QUEUE_CONNECTION=database`, tables `jobs` and `failed_jobs`).
- There are no attachments. No API route accepts a file and nothing is written to object storage: what exists of you exists in columns, not in files.
2. What leaves that server
Two exits, named here because a reader cannot guess them and because a page that keeps quiet about them is worth nothing.
- The judgement engine. The content you send to `POST /v1/ask` and `POST /v1/batch`, the `state` field, up to 128,000 characters (`AskRequest::MAX_STATE_CHARS`), is passed as it stands to a third-party engine hosted outside our server, together with the names of your questions and the model requested. That is the service itself: without that call there is no answer. The name of that provider is given on request at toli@essabu.com.
- That `state` is written nowhere on our side. The job that records a reading does not even take it as a parameter (`RecordReading`), so it cannot end up in a failed job's payload either.
- Outbound mail. The only messages the system sends go to the addresses in `status_subscribers.email`: the subscription confirmation, then the opening, the update and the resolution of an incident. They go through a sending provider.
- Nothing else. The site loads no third-party script, no analytics, no tag manager: there is not a line of it in the site's code or in its dependencies.
3. How one customer is kept from another
A customer is a status page. The separation rests neither on a naming convention nor on the discipline of whoever writes a query: it is in the schema, and PostgreSQL refuses it when it is broken.
- A `page_id` column on every table of the status product, including where it is redundant: `status_daily_statuses` carries it although its service already does. The tenant filter is therefore written `where page_id = ?` at the bottom of every query. A scope somebody forgets to write is visible; a scope that depends on a join vanishes silently the day the join is missing, and sums another customer's ninety days into the figure on display.
- A composite foreign key, so that redundancy cannot lie. `status_daily_statuses(page_id, service_id)` references `status_services(page_id, id)`, and `status_incident_updates(page_id, incident_id)` references `status_incidents(page_id, id)`. A row whose `page_id` contradicted its parent's is rejected by the database, not caught by a code review.
- Uniqueness within a page, never global: `unique(page_id, slug)` on services and incidents, `unique(page_id, email)` on subscribers. Two customers can both own a service called `api`, and one address can follow two pages. Global uniqueness would have leaked one customer's naming into the error another one receives.
- An agent key bound to one page, and the page never named in the URL. The write routes (`PUT /v1/status/services/{slug}`, `POST /v1/status/incidents`, `POST /v1/status/incidents/{slug}/updates`, `POST /v1/status/probes/{slug}`) resolve the page from `api_keys.status_page_id` (`ResolveStatusPageFromKey`). There is no address segment to edit in order to aim at somebody else's page: the only thing that decides is the secret the caller has already proved they hold.
- A scope on each key. Every route asks for the right it uses (`ask`, `batch`, `usage`, `status`, `support`) and answers 403 otherwise: a dashboard key that reads usage cannot spend a cent.
- A custom domain resolves or returns 404. `status.example.com` serves what `/v1/status/example` serves, minus the page segment since the host has already named the tenant. An unknown host has no default page, so nobody lands on another customer by accident.
4. What is hashed, what is in clear
Field by field, because that difference decides what a copy of the database would reveal.
- Hashed with SHA-256 and compared in constant time, therefore unrecoverable, by us included: `api_keys.hash` (the `toli_...` secret), `api_keys.client_secret_hash` (the OAuth client secret), `oauth_access_tokens.hash` (the one-hour token), `status_subscribers.confirm_token_hash` and `status_subscribers.unsubscribe_token_hash`. A lost key is replaced, never recovered.
- In clear, because the service would not work otherwise: `status_subscribers.email`, the subscriber's address; `api_keys.name`, the free text naming your organisation, which is by itself the whole customer record; `readings.questions`, the names of your questions and not their content; the titles and bodies of `status_incidents` and `status_incident_updates`, written by your agent to be read.
- `readings.client_ref` is in clear, under a unique index, and nothing validates its shape: 128 characters of your choosing. If you put an e-mail address or a person's identifier in it, it is stored as such and kept. Put an internal reference there.
- `readings.answers` is worth knowing about: on the `POST /v1/batch` path, the engine's answer is recorded verbatim, with no filtering of its fields. Whatever the engine chooses to include therefore comes to rest in our database. On `POST /v1/ask`, none of the answer is recorded.
- Transport is encrypted over HTTPS, including to the judgement engine. No field, however, is encrypted at rest by the application: what is listed above as being in clear is in clear in the database, and we do not claim an application-level encryption we have not written.
5. What reaches a log line
The question any serious reviewer asks, and the answer is shorter than expected.
- The application writes exactly one log line of its own accord: the failure to send an incident notification, with the page's slug, the incident's slug and the error message. No e-mail address, no IP address.
- No application code reads or records your IP address. The only IP processing in the system is the rate limit on `/v1/oauth/token` and `/v1/oauth/revoke`, where the framework hashes it with SHA-1 into a cache key that lives sixty seconds.
- The web server (FrankenPHP and Caddy) keeps its own access log, with the client IP, the path and the user agent. It sits outside this application, we do not configure it in this repository, and we therefore announce no retention period for it: we enforce none in code.
- An unhandled error is written to the file `storage/logs/laravel.log` with its stack trace. That file is currently neither rotated nor purged automatically.
- By default, the repository writes e-mails into that same file instead of sending them (`MAIL_MAILER=log`). On a deployment where the sending provider has not been configured, the subscriber's address and the confirmation link therefore end up in it in clear.
- A correlation id travels on every response (`x-request-id`). It holds nothing of you: it is your own header if you sent one, otherwise `req_` followed by twelve random characters.
7. Taking your data out
What exists as a route, and what goes through a request. Nothing is invented here: where there is no endpoint, it says so.
- `GET /v1/usage` (scope `usage`) returns your current month's consumption as JSON, read from the `readings` table and not from the cache counter: `period`, `readings`, `input_tokens`, `output_tokens`, then `quota.monthly` and `quota.remaining`. It is a total, not the list of your readings.
- `GET /v1/models` (scope `usage`) returns the available models.
- Your status pages are already served as public JSON, with no key: `GET /v1/status/{page}`, `/{page}/services/{slug}`, `/{page}/incidents` and `/{page}/incidents/{slug}`, as well as the same paths at the root of your custom domain. Everything your public page displays is therefore retrievable with a `curl`.
- There is no export route for the detail of the `readings` rows (including `answers` and `client_ref`), nor for the list of your subscribers. That export is requested at toli@essabu.com and comes back as JSON, or as CSV if you prefer. We extract it by hand: there is no endpoint, and we would rather write that down than promise one.
8. What deletes itself, and what does not
Only two purges are carried out by code. Everything else is kept until somebody asks, and that is the most important point on this page.
- Purged: OAuth access tokens, deleted once they have been expired for more than seven days, by a job scheduled daily at 03:20. A token lives one hour.
- Purged: a subscriber's row, genuinely deleted from `status_subscribers` on unsubscribe, not flagged as inactive. The confirmation token is set back to `null` as soon as it has been used.
- Expires: cache entries, five minutes for resolving a key, sixty seconds for resolving an OAuth token, forty days for the monthly quota counter, sixty seconds for the rate-limit counter.
- Not purged, and said here on purpose: readings (`readings`), the daily history (`status_daily_statuses`), incidents and their updates, pages and services, revoked keys, failed jobs (`failed_jobs`), subscribers who never confirmed, and the log file. No scheduled job touches any of them.
- The ninety days of uptime history are a read window, not a deletion rule. The query that draws the bars bounds the dates it reads; day ninety-one is still in the table. If another text has led you to believe that this history is deleted after ninety days, this page is the one that is right.
9. Deleting your data
Only one deletion is self-service today. The others go through a request to toli@essabu.com, carried out by hand against the database.
- Self-service: unsubscribing. The link carried by the confirmation mail (`GET /v1/status/unsubscribe/{token}`) deletes the row, immediately and without asking you to name the page you are leaving. Said as it is: incident mails do not carry that link today, because only the hash of the token survives the sign-up. It is a known gap, written down in the code, and it will be closed.
- On request: deleting a status page cascades to its services, its history days, its incidents, those incidents' updates and its subscribers, because every foreign key is declared to delete on cascade. Deleting a key cascades to the readings attached to it.
- What survives a revocation: revoking a key deletes nothing. It stamps a date in `api_keys.revoked_at`, drops the cache entry so the refusal is immediate rather than five minutes away, and revokes the OAuth tokens that key had issued. The row stays, with the hash of the secret, so that consumption already billed remains attributable to something.
- What survives a deletion: the web server's access log, kept outside the application, and the restore copies described below.
10. Backups
Backups are taken on the server, outside the application. This repository schedules none: the deployment script merely points at a restore location when a migration fails. We therefore publish neither a frequency nor a retention period, because we enforce neither in code, and announcing a figure that nothing applies would be worse than saying nothing. The consequence for you is stated without hedging: a deletion applies to the live database and does not reach a copy already taken. If you need a written commitment on this point, ask for it at toli@essabu.com: it will be made explicitly, or refused, never assumed.
11. If there is a breach
What we would do, and what a copy of the database would reveal.
- You would be told through the channel we already use with you. Keys are issued by hand, so there is a named person at each end; there is no contact-address column anywhere in the database, `api_keys.name` is the whole customer record.
- The message would say what we know, what we do not know yet and what has already been done. We announce no deadline in hours that nothing obliges us to hold, and we name no supervisory authority we have not gone to.
- What a copy of the database would give: subscriber addresses, the names of your organisations, your `client_ref` references, the answers recorded on the batch path and all the text of your incidents.
- What it would not give: a usable API key, client secret, access token, confirmation token or unsubscribe token, since only their SHA-256 hashes are stored.
- What we would do next: revoke the affected keys, which immediately cuts the OAuth tokens they had issued, then issue you new ones.
12. What does not exist yet
A data handling page is only worth what it refuses to let you believe.
- No certification, no external audit, no data protection officer. We claim none of them.
- No self-service sign-up: keys are issued by hand through an administration command, and there is no accounts table, no billing table and no customer login page.
- Messenger conversation storage does not exist yet on the server side: the widget calls `/v1/support` routes that are not wired. What you would write in that widget goes nowhere and is kept nowhere today.
- The stock `users`, `password_reset_tokens` and `sessions` tables are created by the migrations and stay empty: this service has no user model, no session and no person-level authentication. The `ip_address` column on `sessions` exists and receives nothing. We name them because an audit of the database would see them and ask.
- No IP anonymisation decision has been taken, and no trusted proxy is configured: behind a proxy, the rate-limit key holds the proxy's address rather than the client's.
© 2026 Toli — Essabu Intelligence Provider. All rights reserved.