The core server
openplate-core carries your diary between your devices. It stores an email address and an encrypted copy of your diary. It also keeps your recovery code, sealed under a secret of its own, so a forgotten password returns your diary. That same code lets the operator of an instance open a diary on it.
What it can and cannot read
openplate-core moves a diary between devices. It is the only service in openplate that holds accounts at all. It is a separate deployable with its own image, database and secret, and the browser talks to it directly. The app server proxies nothing on its behalf and serves no sync route.
The diary is encrypted before it leaves the device. The client serializes the local store, gzips it, encrypts it with AES-256-GCM under a random data key, and uploads the result as one opaque blob. The data key is wrapped under a key derived from your passphrase: the passphrase is stretched with Argon2id and split by HKDF into independent branches. Two of them stay on the device and unwrap what is yours, and a third is sent as the login credential. They are siblings, not parent and child, so holding the credential reveals nothing about the key.
The operator holds a recovery key. At signup the app generates a recovery code and wraps the data key under it. It sends the code to openplate-core, which seals it under its own secret. That is what lets a mailed password reset return your diary instead of an empty account. It also means the operator of an instance can restore, and in principle read, a diary on it. On an instance you host yourself, you are that operator. sync.md states the trade in full.
What the server sees beside the ciphertext is stated plainly in PROTOCOL.md §9: an email address, blob size, write frequency and timing, version numbers, and KDF parameters.
The optional study console keeps its own, separate accounts on the same server. Sync says when it is on, and ADR-0008 says why it lives in the app.
What it does know
Being honest about the metadata, because "end-to-end encrypted" is often heard as "the server knows nothing":
- Blob size, and therefore an approximation of how much data the account holds. Compression makes this a fuzzier signal than it was, not a hidden one.
- Write frequency and timing: when a device syncs, and how often.
- Version numbers:
blobVersion,envelopeVersion, and the number of retained versions. - KDF parameters and salt for the passphrase record. These are not secrets; they exist to be served to a new device before login.
- Whether an account has completed setup (has key records) and whether it has ever synced (has a blob).
- The account itself: an email address, an optional display name, a role, a daily AI allowance, a suspension instant, an authentication verifier (a keyed hash of a keyed hash of the passphrase, see §5.8), a second verifier of the same construction over the recovery proof, and the account's KDF parameters. The address names a person in the world, which is a class of personal data 0.5.0 removed and 0.6.0 deliberately put back (ADR-0005): an organization's people are identified by the address their invitation arrived at, because that is the identifier they will still know in a month.
- The account's RECOVERY CODE, sealed (
accounts.recovery_code_escrow, §3.1). This is the entry on this list that a reader should stop at. It is AES-256-GCM under a subkey ofSERVER_SECRET, so a dumped database alone does not open it, and the operator of a managed instance has both. The operator of a managed instance can open any account on it. Not through an endpoint, and not through any code path in this service, but by reading that column with the secret in hand and running the client's own HKDF. A self-hosted instance is its own operator, so the older promise holds there. Deciding whether to trust a hosted instance is therefore a decision about its operator. - Pending invitations: for each, an address, an optional name, a role and an allowance, belonging to somebody who has NO account yet and gave no consent. Minting one is an operator action, and
DELETE /v1/admin/invites/:idwithdraws the row. A row the request door of §5.8.3 minted is marked as such, so an operator can count them. A finished invitation loses its address and name within the hour: a revoked or expired one on every instance, a redeemed one on an instance withTRIAL_ADDRESS_PEPPER, which keeps only the keyed hash below. Without the pepper a redeemed row keeps its address, because the member re-invite rule of §5.21 reads it. - The scan trial, on an instance that runs one: the free scans granted and used, two integers on the account row. For a scan-trial account only, one row per AI action: an opaque id the client chose, a time, a request count and whether an answer was delivered, kept 24 hours and then deleted, and never logged. Each invitation row carries a keyed one-way hash of its mailbox (HMAC-SHA256 under
TRIAL_ADDRESS_PEPPER, a secret only the operator holds, over the trial key of §5.8.3), never a second copy of the address. - After an account is deleted, on an instance that runs a scan trial: the address and the name are removed from every invitation row about that mailbox, and, only when the account held a trial, one keyed hash of the mailbox is kept, and nothing else: no name, no id, and one date, the instant of the deletion, which is what lets the row end. It is what stops the same mailbox from getting a second trial. Without the operator's secret the hash cannot be reversed or matched against a list of addresses. A sweep deletes it
TRIAL_HASH_RETENTION_DAYS(365 by default) after that instant, on the legal basis of Art. 6(1)(f) GDPR (a legitimate interest in preventing abuse of the free scans, not reviewed by a lawyer, ADR-0010). An instance that grants no scan trial keeps no hash. On an instance that runs no scan trial, invitation rows keep their address after a deletion, for the member re-invite rule of §5.21. - AI usage: one integer per account per UTC day, kept for 90 days and then deleted (§5.20). A count, never a log: no prompt, no response, no model, no timestamp beyond the day. An operator can read one account's counters as a day-by-day strip (
GET /v1/admin/accounts/:id/activity), which is metadata about when a person used a health app and is bounded for exactly that reason. - The community pulse, for accounts that turned it on (§5.23, ADR-0007): instance-wide day sums of meals, photographs, calories and grams of protein, one row per contributing account per day, and a short lived presence row saying that an account is fasting right now. The sums are not attributable to anybody; the contributor row and the presence row are, and they say only "this account contributed today" and "this account is fasting". Day sums and contributor rows are kept 30 days, presence expires 30 minutes after the last heartbeat, and the routes log no account id. A person who never turned it on sends nothing and appears in none of it.
- A push subscription, for a device whose owner turned notifications on (§5.24, ADR-0008): the push service endpoint, the two keys it encrypts to, a capped user agent string, an IANA time zone, a locale, the minute of the local day a catch-up is due, the local day one last went out, the local day the device was last seen, the instant it asked to be woken, and a count of what has been sent today. Together those say roughly when this person is awake, roughly where in the world they are, and, through
wake_at, when a fast of theirs ends. That last one lines up with the pulse's presence row, which says the same fast is running; ADR-0008 names the correlation rather than leaving it to be discovered. What is NOT stored is a word of any notification's text: every push carries a kind. The row goes when the device unsubscribes, when the push service disowns it, or with the account. - A health-data consent, on an instance that asks for one (§5.15.1): the version of the wording the person agreed to and the instant this service recorded it, two columns on the account row. It says that the person uses a health app and agreed to have the operator process that data, which the operator must be able to show. It is visible to an operator (§5.20), no route clears it, and it goes with the account row on deletion.
- When a person last did something:
accounts.last_seen_at, written by a login and by a proxied completion, and deliberately not by a token refresh or a sync poll, so it means "somebody acted" rather than "a client was running". It is visible to an operator (§5.20) and goes with the account row on deletion. - Statutory declarations (
POST /v1/legal/declarations, a cancellation or a withdrawal, on every instance): the name, the address, the contract reference, the reason and the dates the person typed, the time it arrived, and the account it matched, if any. It is kept until the end of the third calendar year after the year it arrived, counted in Europe/Berlin time, and then deleted by the hourly sweep: one received on 2026-09-21 is deleted from 2030-01-01 00:00 in Berlin. Deleting the account does not delete it earlier; the row loses its account id and stays, because it is the record of what the person declared. Receipt mail is capped at three per normalised address, atLEGAL_DECLARATION_RECEIPTS_PER_NETWORK_PER_DAY(10 by default) per sender network, and atLEGAL_DECLARATION_RECEIPTS_PER_DAY(200 by default) per instance. Each cap applies over any trailing 24 hours. The totals are counted from these rows, except the network count, which stays in memory. A declaration over a cap is still stored, forwarded, and sent to the operator, and the202is the same. The receipt never repeats the name, the contract reference, or the reason; only the operator's copy carries them. - Session metadata: how many active sessions exist, when each was created, and when tokens were last rotated or revoked. Token values themselves are stored only as digests.
- The study graph, on a deployment with
SYNC_RESEARCHset (§5.18): which account contributes to which study, when, how often, and how large each contribution is. An edge here says "this person's health data is in study Y", which is health-adjacent personal data of the same class as the care edge below. It is unavoidable, and withdrawal is the proof: erasing a contributor's row requires locating it, account deletion must cascade through it, and both the compare-and-swap and abuse control key on the account. A scheme that blinded the server would break one of those and traffic analysis would un-blind it anyway, so this is disclosed rather than half-avoided. The researcher never receives the mapping (§5.18 carries no account id), withdrawal hard-deletes the edge and leaves only a pseudonym, and a deployment without the flag has no table to hold a study graph. - The sharing graph, on a deployment with
SYNC_SHARINGset (§5.16): which account has granted read access to which other account, when the grant was made, and when the grantee exercises it. That is a relationship graph, and a genuine expansion of what this service knows, and in the setting the feature was built for (a patient and their dietician), an edge in that graph is itself health-adjacent personal data, because it says someone is under care. It is the minimum needed to authorise the read; both ends consent, since the grantor creates the row and the grantee can delete their side; and the edge is hard-deleted on revocation and cascades away when either account is deleted. A deployment that does not setSYNC_SHARINGstores no such graph and has no table to put one in.
- Reported estimates, on a deployment with
SYNC_FEEDBACKset (§5.25, ADR-0006): the figures of each entry a person chose to report, the plate photograph when the device still had one, the account id, the consent record and the arrival time, all readable. They are kept forinstance.feedback.retentionDays, then deleted by a sweep, and they go with the account. An operator can read them through §5.20, and every read of a photograph is logged. A deployment without the flag has no report and no photograph to hold.
Not knowable from the metadata above: what was eaten, when, how much, or anything else inside the payload. Two entries above do give it away. The sealed recovery code opens the whole diary to whoever also holds SERVER_SECRET, and a reported estimate shows the one entry it carries.
Managed instances
An instance can set INSTANCE_MODE=managed (see configuration.md). That declares one thing: an organization runs this instance, invites its people by email, and gives each one a daily AI allowance. openplate-core is what carries that, the account it already holds for sync also holds the allowance, so there is no second connection step and no second credential.
To the browser it is unchanged: a signed-in account with an allowance scans through the AI proxy openplate-core exposes, the same service the client already talks to for sync. To the thing behind it, openplate-core is a client: it points at either a cloud provider or your own openplate-inference container. Inference is the compute layer, openplate-core is the tenancy layer on a managed instance, and they compose: the core server carries no model and answers no scan itself.
It is on the photo path, which is the honest cost of it, and the mitigation is a property of the code rather than a setting: the logger's field type admits primitives only, so a body cannot reach a log line, and upstream error strings are scrubbed before they are logged or returned. Members of an organization share spend, not data; a plate photo that reaches the proxy is read once and not stored.
The allowance counts requests, not currency. An operator can also cap the whole instance per day (AI_INSTANCE_DAILY_LIMIT), and a spend cap on the upstream key at the provider is still required.
An administrator runs the instance from /admin in the app: people and their allowances, invitations, activity, reported estimates, and which reference values the Nutrients screen quotes.
History
From August to September 2026, this was a separate service, openplate-gateway: a small OpenAI-compatible proxy holding one upstream key and issuing each member an opk_… token with its own daily quota. M192 (September 2026) merged it into openplate-core: one account now carries both the diary and the allowance, so there is no second service, no second invite link, and no second credential to hand out.
Docs: protocol
Releases: release notes