2026-10-21 20:37:39 +02:00
|
|
|
|
# ADR 0019: Private studysets
|
|
|
|
|
|
|
|
|
|
|
|
- **Status:** decided 21 Oct 2026, by the developer (D1–D8 below).
|
|
|
|
|
|
- **Amends:** ADR 0002, by the way back it left open: a private studyset's content is no
|
|
|
|
|
|
longer readable by the instance.
|
|
|
|
|
|
- **Builds on:** ADR 0017 (the account's keys; D5, private per studyset and by default),
|
|
|
|
|
|
ADR 0004 (tags, settings, files), ADR 0005 (publishing, Explore's copies, the learner
|
|
|
|
|
|
count).
|
|
|
|
|
|
- **Followed by:** ADR 0018 (the agenda), whose store uses the same keys.
|
|
|
|
|
|
|
|
|
|
|
|
## Context
|
|
|
|
|
|
|
|
|
|
|
|
ADR 0017 gave every account key pairs that only its devices can open, and decided (D5)
|
|
|
|
|
|
that a studyset, with everything inside it, is private or not, new ones private. ADR 0002
|
|
|
|
|
|
had kept `keyId` and `nonce` in the envelope for exactly this, with the server's features
|
|
|
|
|
|
skipping what they can't read.
|
|
|
|
|
|
|
|
|
|
|
|
What a studyset holds is spread over Learn's synced tables: the studyset's own payload;
|
|
|
|
|
|
its cards', notes' and steps' revisions; its learning paths and PDFs; the answers given in
|
|
|
|
|
|
its paths (a plain `answer` column); review and reading logs; the links to tags; and the
|
|
|
|
|
|
files its content names, kept per space by their hash. Tags and settings belong to the
|
|
|
|
|
|
person rather than to a studyset, yet hold content too: tag names (Jellyfin's show names
|
|
|
|
|
|
among them), each studyset's study tags, its Jellyfin ties.
|
|
|
|
|
|
|
|
|
|
|
|
The server reads payloads in four places: the Claude connector, the learner count (each
|
|
|
|
|
|
person's `countLearning` and the publication a copy came from), publishing (the tree, its
|
|
|
|
|
|
revisions, its files and its tags' names), and feedback (threads about others'
|
|
|
|
|
|
publications, readable by design). It checks that a file over 20 MB is a PDF. Every
|
|
|
|
|
|
change the sync engine records carries its time (the writing device's clock, to the
|
|
|
|
|
|
millisecond), its table and the columns it changed.
|
|
|
|
|
|
|
|
|
|
|
|
## Decision
|
|
|
|
|
|
|
|
|
|
|
|
1. **Privacy belongs to a top-level studyset** and holds for everything inside it. A
|
|
|
|
|
|
topic moved into another tree takes on that tree's privacy, its rows encrypted anew on
|
|
|
|
|
|
the device.
|
|
|
|
|
|
2. **Keys.** A private studyset has a data key of its own (ADR 0017's `DataKey`), and
|
|
|
|
|
|
every person has one personal key besides, for their tags and settings. Each is sealed
|
|
|
|
|
|
to the account's X25519 key and kept in a new synced table in the person's space
|
|
|
|
|
|
(`content_key`), so keys arrive with the data, work offline and go with the space
|
|
|
|
|
|
(D6). An anonymous person's keys are sealed to the device's own key pairs (ADR 0017,
|
|
|
|
|
|
9). A person whose account has no keys at the register (signed in with GitHub, no
|
|
|
|
|
|
password yet) gets nothing private until they set a password, which the app leads to
|
|
|
|
|
|
(D8); then the library makes the offer of 7.
|
|
|
|
|
|
3. **What is encrypted.** With the studyset's key: the payloads of the studyset, of its
|
|
|
|
|
|
cards', notes' and steps' revisions, of its learning paths and PDFs, and of the asset
|
|
|
|
|
|
rows of its files; the answers in its paths (`learning_path_answer` gains `keyId` and
|
|
|
|
|
|
`nonce`); and the files themselves (D5), stored under the hash of what is encrypted, so
|
|
|
|
|
|
the same picture in two studysets is stored twice. With the personal key: the payloads
|
|
|
|
|
|
of tags and of the settings. Each is ADR 0017's sealed content: XChaCha20-Poly1305 with
|
|
|
|
|
|
the key's ID and the row (table, id, column) as additional data, so the instance can't
|
|
|
|
|
|
move a payload to another row, and the author's Ed25519 signature, checked before
|
|
|
|
|
|
anything is decrypted, which the payload column carries before the ciphertext. The
|
|
|
|
|
|
plaintext inside is the payload as before, its `schemaVersion` with it.
|
|
|
|
|
|
4. **What stays readable** (D7): the studyset tree and positions; when a studyset changed,
|
|
|
|
|
|
was starred, archived or deleted; which card was rated how, when, for how long and in
|
|
|
|
|
|
which mode; the PDF pages reached; path answers' outcomes; file sizes; and, from the
|
|
|
|
|
|
sync engine, when each row changed, how many rows each table holds and how they hang
|
|
|
|
|
|
together. The privacy page lists it: the instance sees a private studyset's shape, size
|
|
|
|
|
|
and rhythm, never its content.
|
|
|
|
|
|
5. **The server's features.**
|
|
|
|
|
|
- Publishing refuses a private studyset and says how to open it up. A non-private
|
|
|
|
|
|
release's tag names come from the author's device with the call (D2); the server
|
|
|
|
|
|
checks they name exactly the tags its items carry.
|
|
|
|
|
|
- The connector leaves private studysets out and tells Claude so.
|
|
|
|
|
|
- The learner count: while a person's Count me is on, their device reports which
|
|
|
|
|
|
publications their copies come from (D3), kept server-side and replaced with each
|
|
|
|
|
|
report; the review log still gives the days studied. Count me itself moves into a
|
|
|
|
|
|
setting the server keeps (D2), as the settings' payload is encrypted.
|
|
|
|
|
|
- Feedback is unchanged: it is about others' publications, written to be read.
|
|
|
|
|
|
- Dictionary lookups are unchanged: searches, a studyset's corrections (by its cards'
|
|
|
|
|
|
entry ids) and the Watch screen's lines (unless the device holds the dictionaries)
|
|
|
|
|
|
still go to the instance, as the privacy page says.
|
|
|
|
|
|
- A file's upload says whether it is encrypted, and only a readable one is checked to
|
|
|
|
|
|
be a PDF; the quotas count both alike.
|
|
|
|
|
|
6. **Changing it.** Making a studyset private encrypts each of its rows in place (a
|
|
|
|
|
|
payload update, as blanking is) and its files anew; making it non-private decrypts
|
|
|
|
|
|
them likewise. The readable copies in the instance's backups stay until the backups
|
|
|
|
|
|
expire; the app says so where it asks.
|
|
|
|
|
|
7. **What exists already** stays as it is (D4): the library offers once to make every
|
|
|
|
|
|
studyset private, and each studyset's menu changes it later. New studysets are
|
|
|
|
|
|
private, copies from Explore too.
|
|
|
|
|
|
8. **Keys lost** (an account reset without the recovery phrase): private studysets
|
|
|
|
|
|
whose keys no longer open show as locked, with Delete alone.
|
|
|
|
|
|
9. **Exports** decrypt on the device: an archive is readable wherever it is kept, which
|
|
|
|
|
|
the export sheet says.
|
|
|
|
|
|
10. **Released with build 22** (D1), whose `minimumAppBuild` already locks older apps out,
|
|
|
|
|
|
so the new synced table and columns cost no second lockout.
|
|
|
|
|
|
|
|
|
|
|
|
## Decisions (21 Oct 2026, the developer's)
|
|
|
|
|
|
|
|
|
|
|
|
- **D1** Ship with build 22: one lockout of older apps for passwords and private
|
|
|
|
|
|
studysets alike.
|
|
|
|
|
|
- **D2** Tags and settings are encrypted with a personal key; a release's tag names come
|
|
|
|
|
|
from the author's device; Count me becomes a setting the server keeps.
|
|
|
|
|
|
- **D3** Copies are counted by the device reporting their publications, only while Count
|
|
|
|
|
|
me is on.
|
|
|
|
|
|
- **D4** Existing studysets stay as they are, with a single offer to make them all private.
|
|
|
|
|
|
- **D5** A private studyset's files are encrypted too.
|
|
|
|
|
|
- **D6** Data keys live in a synced table in the person's space.
|
|
|
|
|
|
- **D7** The shape, size and rhythm stay readable and are listed; encrypting the values
|
|
|
|
|
|
(ratings, durations, positions, pages) would hide how well, not when or how much, as the
|
|
|
|
|
|
sync engine records every change's time.
|
|
|
|
|
|
- **D8** An account without keys at the register keeps its studysets readable until it
|
|
|
|
|
|
sets a password.
|
|
|
|
|
|
|
|
|
|
|
|
## Consequences
|
|
|
|
|
|
|
|
|
|
|
|
- Build 22 brings a migration of synced tables (`content_key`; `keyId` and `nonce` on
|
|
|
|
|
|
`learning_path_answer`), explained with its SQL when made.
|
|
|
|
|
|
- Every payload codec reads and writes through the keys; a row whose key the device lacks
|
|
|
|
|
|
is shown as locked, never half-read.
|
|
|
|
|
|
- ADR 0002's rule 4 (server features read payloads) now holds for what isn't private;
|
|
|
|
|
|
rule 6's way back is taken.
|
|
|
|
|
|
- Making a large library private takes a sync of everything in it.
|
|
|
|
|
|
- Nobody, the operator included, can restore a private studyset whose keys are lost.
|
2026-10-21 21:50:29 +02:00
|
|
|
|
|
|
|
|
|
|
## As built (21 Oct 2026)
|
|
|
|
|
|
|
2026-10-21 23:07:47 +02:00
|
|
|
|
- **Files** (D5, built 21 Oct): a private studyset's file is sealed with its
|
|
|
|
|
|
tree's key for its asset row before it is hashed; the row's hash and size
|
|
|
|
|
|
and the file uploaded (`encrypted`, which skips the PDF check) are the
|
|
|
|
|
|
sealed file's, and its sealed payload carries the file name and the hash
|
|
|
|
|
|
of what was sealed (asset schema 2). The device keeps the sealed file to
|
|
|
|
|
|
upload and check, and the opened one by its own hash for what reads files
|
|
|
|
|
|
by path. A file is a row per key. Every place adding a file names its
|
|
|
|
|
|
studyset (the editors through `StudysetFiles`, which every screen of a
|
|
|
|
|
|
studyset and the PDF reader set; PDFs, screenshots, imports, copies,
|
|
|
|
|
|
whose top studyset is now made before their files; accepted suggestions
|
|
|
|
|
|
by their item's tree). Making a tree private or not, or moving a topic
|
|
|
|
|
|
into another tree, adds its files anew under the tree's key, names the
|
|
|
|
|
|
new rows in its content (whole `asset:` values and a PDF's file, never
|
|
|
|
|
|
text) and blanks the old rows nothing else names.
|
|
|
|
|
|
- **The marker** (the developer's, 21 Oct): on the library's cards and the
|
|
|
|
|
|
studyset's Share alike, a lock for private, an open lock for readable by
|
|
|
|
|
|
the instance and not published, a globe for published. The locks open
|
|
|
|
|
|
one dialog saying what that means and changing it (and, while not
|
|
|
|
|
|
private, leading to the Publish sheet); the globe opens the Publish
|
|
|
|
|
|
sheet.
|
2026-10-21 21:50:29 +02:00
|
|
|
|
- **Count me** stays in the person's settings, which follow them to every
|
|
|
|
|
|
device, and the device mirrors it to the instance after each sync
|
|
|
|
|
|
(`counted_learner`), reporting the copies only while it is on: the
|
|
|
|
|
|
instance keeps the setting, as D2 asks, but the person's choice is still
|
|
|
|
|
|
made where it was.
|
|
|
|
|
|
- **Tag names:** the instance refuses a name sent for a tag that isn't the
|
|
|
|
|
|
person's and a release whose encrypted tag has none; names of the
|
|
|
|
|
|
person's tags the release doesn't carry are ignored rather than refused,
|
|
|
|
|
|
as the device sends all its tags' names.
|
|
|
|
|
|
- **Locked rows:** a studyset whose key doesn't open shows as locked; its
|
|
|
|
|
|
other rows read as blank, which the lists leave out; a tag or settings
|
|
|
|
|
|
row that doesn't open is left out entirely, so nothing folds or deletes
|
|
|
|
|
|
it on a device without the keys.
|
|
|
|
|
|
- **Identity binding:** each payload is sealed for its table, row id and
|
|
|
|
|
|
column, and signed by the account; the signature goes before the
|
|
|
|
|
|
ciphertext in the payload column.
|
|
|
|
|
|
|