halkyon/docs/adr/0019-private-studysets.md

156 lines
9.7 KiB
Markdown
Raw Normal View History

# 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.
## As built (21 Oct 2026)
- **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.
- **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.