Security and data
Where your book sits, and who can read it.
We hold no SOC 2 report, no ISO certificate and no penetration-test result, and this page cites none, because there is nothing to cite. What it does instead is describe how the thing is built, in enough detail that you can hold us to it, and name the parts that are commitments rather than running systems.
Read this first
Nothing is deployed yet.
There is no live service. The cloud project this will run in contains one storage bucket holding deployment state, and nothing else: no database, no servers, no material belonging to anybody.
That decides what you should take from this page. Everything below is a property of the system as built and tested, not a claim about a service that is currently up. We are not going to quote you an availability figure, because we do not have one to quote, and a status page with no history behind it is a liability rather than a proof.
The first provider to send us a book will be the first material in the system. If that is you, you are entitled to ask harder questions than this page answers, and to ask them of a person rather than a form.
| Currently deployed | Nothing. One deployment-state bucket |
|---|---|
| Where it will run | Google Cloud, region us-central1 (Iowa, United States) |
| Database | One PostgreSQL 17 instance, one schema |
| Tables with row-level security | 25 of 25, enabled and forced |
| Database role the application connects as | NOSUPERUSER, NOBYPASSRLS, owns no table |
| Service-account key files | None. The resource type is banned in the repository |
| Database credentials held by the document worker | None |
| Certifications cited on this page | None |
| Answer to a request for an organisation you are not in | 404, never 403 |
We will not put a trust badge on this page, and we will not build a trust centre that turns out to contain three pages we wrote ourselves.
Where it sits
Four places, and what can reach each of them.
A file you send exists in a small number of specific places. This is all of them, in the order it passes through.
Stop 1
Your browser
Sends the file to a link signed for 15 minutes. It never touches our API.
Stop 2
Documents bucket · us-central1
Versioned. No rule deletes a live object. A deleted object is recoverable for 7 days. Public access blocked at the bucket itself.
Stop 3
Document worker
Reads that bucket and cannot write to it. Holds no database credential, no key file and no password. Identified per job by a token minted for that job.
Stop 4
API, then PostgreSQL 17
The only writer to the database. 25 tables, row-level security on all 25. No authorised networks. Encrypted connections only.
The upload
Your browser sends the file straight to storage against a link signed for fifteen minutes. It does not pass through our API on the way, so there is no copy of it in a request log, in a temporary directory or in a queue. Six file types are accepted at the door — PDF, Word, PowerPoint, PNG, JPEG and plain text — and the ceiling is 250 MB per file. Anything else is refused rather than stored and sorted out afterwards.
Accepted is not the same as readable, and the difference is ours to carry rather than yours to discover. The reader currently handles PDF and plain text; a Word file, a slide deck or a photograph is stored and then reported back as something it cannot process, and turning it into a PDF is our work. We would rather accept the file you actually have and do that conversion than refuse it at the upload and leave you wondering what format we wanted.
The two buckets
Your book goes into one bucket and stays there. It is versioned, and there is no rule anywhere in the configuration that deletes a live object. That is a product requirement rather than a storage preference: every citation in your course is verified by re-reading the page it came from, so a rule that quietly removed a PDF after a year would break the checking with no error until a student clicked something. Objects move to cheaper, slower storage at 90 days and again at 365, which changes the price and not the availability. A deleted object stays recoverable for 7 days.
Everything derived from your material — page images, the output of reading a scan, thumbnails — goes into a second bucket and is deleted at 180 days, because all of it can be rebuilt from the first. The part of the system that reads your originals cannot write to them.
The database
One PostgreSQL 17 instance. It has no authorised networks at all — not a narrow list, an empty one — accepts encrypted connections only, and is reachable solely through a socket mounted into our own service and gated by cloud identity. There is no bastion host, no VPN, and no password on a laptop that reaches it.
Encryption at rest is the cloud provider’s default for both the database and the buckets. We have added no customer-managed key, and this page is not going to describe a vendor default as though we had built it.
Region, said plainly
One region, in Iowa, in the United States. The deployment configuration refuses any other value before it runs, so the region cannot drift by accident. If you need your material to stay inside the EU, we cannot do that today: it is a second deployment in a second project, and it is not built. We would rather tell you here than after you have paid us.
Who can read it
What a request has to get past.
Four checks, in this order, on every request that touches anything of yours. Three are ordinary. The fourth is the one worth reading.
- 01
Which account is this
Sign-in is handled by a separate identity service that answers exactly one question: which account holds this credential. We never see a password. No role, no organisation and no permission is ever written into the sign-in token, because a token is a cached answer and a member you remove has to stop working immediately rather than in an hour. - 02
Are you a member of this organisation
One query returns the organisation and your membership together, so “you are not a member” and “there is no such organisation” are the same answer and take the same time to produce. Both are a 404. A 403 would confirm the organisation exists, and because organisations can also be looked up by name, that would turn the API into a way of listing our customers. - 03
Does your role carry this permission
31 named permissions across five staff roles and the student. A route declares the permission it needs and never learns which roles satisfy it. Nobody may act above their own level: an admin cannot promote anyone to owner and cannot remove one, and the last owner of an organisation cannot be removed at all. - 04
The database is told whose request this is
The tenant is set as a value local to the transaction, so it is cleared the moment that transaction ends and cannot leak on to the next request borrowing the same connection. Every table’s policy is evaluated against it, and with it unset every table returns zero rows rather than everything.
The part that is unusual
Separation between providers is enforced by PostgreSQL itself, not by our code remembering to add a filter to each query. All 25 tables have row-level security enabled and forced, each carrying one policy under the same name. The check that verifies this reads the database catalogue rather than a list somebody maintains, so a table added next year without a policy fails the build instead of quietly returning everybody’s rows. There is no exception list, deliberately: an exception list is a thing people add themselves to.
The role the application connects as owns nothing, cannot become a superuser and cannot bypass a policy. It could not switch its own isolation off if a bug told it to. It also holds no TRUNCATE privilege anywhere, because TRUNCATE is not filtered by row-level security at all, so holding it would be a cross-provider delete that no policy could see. Migrations run as a different role, and no running service holds that credential.
Rows are anchored to their parents by a two-column key that carries the organisation with it. A lesson cannot be created in your account pointing at another provider’s module — not because a developer remembered to check, but because there is no row for it to point at. That is the specific mistake that leaks data in a system shaped like this one, and it recurs at every parent-and-child edge, which is why it is answered once at the bottom instead of twenty times in handlers written on twenty different days.
Your staff, and taking access away
Suspend somebody and they are signed out immediately: their sessions are revoked at the identity service and a timestamp is stamped on their record that every later token is compared against. Not when their cookie happens to expire, which for a session cookie is days. It is the thing everybody assumes already works.
A staff invitation is 32 random bytes. Only its SHA-256 hash is stored, it is shown once and never again, it can be used once, and it dies after 14 days. Nothing is emailed — an admin copies the link and hands it over — and a revoked token, an expired one and one addressed to somebody else all produce the same answer, so a stranger holding a link learns nothing from the way it fails.
Sub-processors and retention
Who else touches it, and what we keep.
The complete list, including the two that are configured but switched off. Something switched off still belongs on this list, because the honest question is what could read your material rather than what read it last week.
- Google Cloud
- The database, both storage buckets, the servers, the job queue, the secret store and the logs. Region us-central1. This is where your material actually sits.
- Firebase Authentication
- Sign-in only, and part of the same company. It holds the account and the password; we never see one. It is told nothing about your organisation, your role or your material.
- Vercel
- Serves the web pages. Course media and source files are served by signed links straight from cloud storage rather than proxied through it, so the contents of your book do not pass through this one.
- Google Document AI
- Reads scanned pages that carry no text layer. Switched off in the deployment configuration today, and while it is off the interface is not even enabled on the project.
- Sentry
- Error reports from the API. Optional, currently unkeyed, so nothing is sent. Anything sent passes the same redaction as the logs, which strips credentials, tokens and connection strings.
- PostHog
- Product analytics. Optional, currently unkeyed, the same state as above.
No advertising network, no tag manager, no session recorder, no chat widget. There is no third-party script on this website and none on the screen your students look at — not as a policy we intend to adopt, but as a fact about the pages as they are written today.
We will not add a sub-processor without changing this list first, and the list is a page you can bookmark rather than a clause you have to ask for.
What we keep, and for how long
| Source files you send | Kept. No rule deletes a live object |
|---|---|
| A deleted file stays recoverable for | 7 days |
| Page images and reading output | Deleted at 180 days, rebuilt on demand |
| Database backups | Daily, 30 retained in production |
| Point-in-time recovery window | 7 days of transaction logs in production |
| Enquiries sent through the contact form | Deleted at 180 days |
| Audit records | Kept, and the application cannot edit or delete them |
| Restoring one provider without touching another | A written runbook, not a button |
What a restore actually involves
Backups cover the whole database, because every provider is in one database. Restoring your data alone means restoring a copy of the entire instance to a scratch machine at a chosen moment and copying your rows back in dependency order. That is a person with a runbook and it takes hours rather than minutes. We would much rather you knew that before an incident than during one.
We chose a shared database knowingly. A separate database for each provider would have made this one command, and it would have turned signing up into a provisioning project with its own failure states. The trade is written down, with the alternatives that lost, in the architecture decision record that fixes the tenancy model.
What we know about your students
An account identifier at the identity service, an email address if the provider released one, a display name, which course they are enrolled in, which lessons they have opened and how they answered the questions. No home address, no telephone number, no date of birth, no payment detail. The product holds no card details and contains no payment code at all — there is nothing in it that could take a payment.
The contact form on this site stores a name, an email address, an optional school name and the message, plus a keyed hash of the sending address rather than the address itself, because the only question ever asked of it is whether the same person pressed the button twice in ten minutes. The database’s own query monitoring is configured not to record client addresses.
The audit trail
23 kinds of event are recorded: every change to who can do what, and every change to what your students can see. The application role holds no privilege to update or delete those rows, so the trail is append-only as a matter of database permission rather than of good intentions. Removing a member does not erase the record of what they did; the actor is simply unlinked from it.
An audit row never contains an email address, only its domain, and that follows from the same decision: a row that can never be deleted must not hold anything a person could later ask us to erase. There is no screen showing the trail yet. Today it is read with a query, and saying so is cheaper than implying a console that does not exist.
Who at our end can reach your material
There is no platform-level administrator account in the product. Nobody can sign in as a support engineer and open your course, and adding that ability would be a change to the most security-sensitive path in the codebase rather than a setting somebody flips. That is a real property and it is worth having.
What it does not mean is that nobody can reach your data. Whoever operates the database can, exactly as at every other supplier you have ever bought from, and a page that implies otherwise is lying to you. What we can say is that it is a small number of people, that it takes a deliberate act rather than a click, and that we will tell you if it ever happens for a reason other than fixing your own problem.
Disclosure
Reporting a security problem.
Use the contact form and put the word security in the first line. It reaches the same person as everything else, because there is one person.
We will confirm we have it within two working days, tell you what we found, and tell you when it is fixed. There is no bounty programme and we are not going to invent one. We will not threaten anybody who reports a fault in good faith, and we will credit you by name if you want that and say nothing if you do not.
- We will not claim a certification we do not hold, or imply one by describing the audit we think we would pass.
- We will not publish an uptime figure before there is a service to measure.
- We will not answer a security questionnaire with a yes we cannot point at code for.
- We will not describe anything on this page in the present tense before it is built.
Everything above is checkable. The fastest way to check it is to send us a chapter.
Send one chapterWhere the file sits is only half of the question. The other half is who owns what comes out of it, and what happens to it on the day you stop paying us: Your material
Send one chapter.
Not a demo of somebody else’s course. A chapter of yours, converted properly, so you can look at your own questions and your own page numbers and decide whether it is any good. Pick the chapter you know best. If it is no good, you have lost an email.
We will ask who owns the copyright before we convert anything. If you do not hold the rights to the material, we will say so rather than take the work.