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

283 lines
17 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 (published before or not), a
shield with a warning for private but partly readable or unsealed (the
reading of 22 Oct), 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; since the reading of
22 Oct the device sends only the names of the tags the release carries.
- **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.
## The reading (22 Oct 2026)
A second, independent reading found that the device decided what was
private, and which key to write with, from columns the instance controls
(the top studyset's `keyId`, `parentId`, `card.studysetId`), and trusted
every readable row; and that content stayed readable in ordinary use (cards
moved, pictures from the full-window editors, a rewrite cut short, a device
that hadn't heard yet). The developer decided:
- **R1** Private studysets hold against an instance that alters data, not
only one that reads it.
- **R2** Rollback is guarded for the settings alone (a counter inside their
sealed payload); other old sealed content handed back is integrity, not
confidentiality, and is accepted.
What changed:
- **Privacy is a signed fact.** A data key's ID says what it is for
(`s1.<studyset>.<ms>.<nonce>` a studyset's key, `p1.<ms>.<nonce>` the
person's, `r1.<studyset>.<ms>.<nonce>` a record that the studyset was
made readable, which ends its earlier keys); the ID is sealed with the
key and signed by the account, so the row's columns are never trusted. A
studyset is private while the latest signed record about it says so; the
device keeps the latest it saw (`privacy.json` in the person's folder,
never synced), so a record withheld or a row replayed changes nothing. A
tree's key is the nearest studyset's with such a record, itself or above,
so moving a private studyset under a readable one changes nothing.
- **Items keep their key.** The device keeps the key each item (a card, a
note, a path, a step, a PDF, a step's answers, a studyset, a file) was
last seen sealed with, and never writes it with a weaker one unless it
moves the item itself or a signed record makes its tree readable.
- **Readable rows are signed or distrusted.** A device holding the keys
signs every readable row it writes (the account's signature in place of
the nonce, for its table, row and column). Where privacy holds (a private
tree, an item once sealed, tags and settings once sealed), a readable row
nobody signed is locked: never shown, folded or adopted. A signed one,
from a device that hadn't heard yet, is shown and sealed anew.
- **Rewrites finish.** A rewrite is kept as under way (in the ledger) until
done, writes the top studyset last, and holds writes into its tree
meanwhile; as the data opens and after every sync that merged, unfinished
ones are finished and every private tree is swept for rows not under its
key. What stays unsealed (locked rows, files not had) shows on the marker
as partly readable, with Finish in its dialog.
- **Moves.** Cards moved into a private tree are sealed with their files
before they move; cards of a private tree are never moved into a
readable one: the app asks to make the target private first. A private
topic moved out of its tree (into a readable one, or to the top) keeps its
privacy with a key of its own (this changes Decision 1: privacy can now
be a topic's own).
- **Tags and settings** are sealed once with the personal key, all readable
ones then; from then on a readable one nobody signed is left out, so the
instance can't confirm a guessed name by having a sealed tag folded into
it. Settings carry a counter (`HLKS`, 8 bytes, then the payload, inside
the seal); one lower than seen is refused.
- **A device without the account's keys,** on an account that has them,
writes nothing that would be private (new studysets, tags, settings are
refused, and it says so), reports nothing for the learner count, and
shows private studysets as needing the password here, not as lost
(archiving stays). An anonymous person's keys kept but unreadable are
never replaced.
- **Files.** The full-window editors carry the studyset they were opened
from; where none is known, an account with keys adds no file (a new
studyset's description takes pictures once it is made). A file no live
row names is never uploaded. A suggestion from a private copy uploads
readable copies of its files for the author; accepting swaps whole
`asset:` values only. Old file rows are kept while a row this device
can't open, or a sent suggestion, might name them.
- **The instance:** an upload said to be encrypted must start as a sealed
file does (`HLKF`, version 1); a person's room counts the files a live
row names and those claimed within the week of grace; copy reports are
limited to 60 an hour; publishing gets only the release's tags' names.
Residual risks, written down:
- **Any device can be held at any earlier genuine record:** the instance
can withhold the records made since a device last synced, so it acts on
the latest it saw. A fresh device, which saw none, is the case that
matters: shown a studyset never made private as far as it knows, and an
old readable row, it can be made to write readable there. The person's
other devices still see the signed records once they come.
- **First sealing of tags and settings** on each device trusts what is
readable then: rows the instance planted before become the person's.
- **Old sealed content handed back** (a title, a card's earlier revision)
shows as it was: integrity, not confidentiality (R2).
- **Structure and rhythm** stay readable as before (D7); a card's place in
a tree is the instance's to change, which the item's own key keeps from
mattering for what is written, not for where it shows.
## The short reading (22 Oct 2026)
A short reading of the reading's fixes, its findings confirmed by probes on
the real gateways; each probe is a test now.
- **Rewrites stay in their own keys.** A rewrite or sweep changes only rows
under no key, under the target key, or under a key whose signed ID names
the studyset rewritten; rows under another studyset's key (which the
instance could have moved in) are left as they are. Only the person's
own moves re-key rows of other keys.
- **Item keys can't be led astray.** An item's key is never replaced by a
key a record ended, nor by another studyset's while the one kept holds,
and an item is written with its own key before its tree's.
- **Pending rewrites** the latest record no longer asks for are dropped,
so a make-readable cut short never runs over a later private record.
- **Failures stay small.** A file that won't move is counted and owed (the
tree partly readable meanwhile); each step of the upkeep fails alone.
- **Settings carry a version vector** (each device's count of writes,
`HLKV`): a row is a rollback only when it brings news from no device, so
two devices' changes at once both count.
- **Cheaper upkeep:** sweeps look rows up by their key, skip those known
unmovable, and ask for a sync only when they wrote.
- **One rewrite at a time,** each writing back only rows unchanged since it
read them.
- **Records outrun the clock:** a new record is made later than the latest
known about its studyset.
- **Fail-closed:** an unreadable `privacy.json` is kept aside and nothing
private is written until the keys are opened again; whether the account
has keys is the register's to say (`signInWays().password`), not the key
rows'.
- **Publishing** is refused on the device where anything in the tree is
sealed, before any tag's name goes out; a suggestion leaves sealed files
out.
Left open: the Publish sheet doesn't yet list the tag names it sends; Finish
doesn't offer to adopt unsigned edits after asking; a suggestion from a
private copy carries no files.