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
|
2026-10-22 09:29:56 +02:00
|
|
|
|
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
|
2026-10-21 23:07:47 +02:00
|
|
|
|
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
|
2026-10-22 09:29:56 +02:00
|
|
|
|
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.
|
2026-10-21 21:50:29 +02:00
|
|
|
|
- **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.
|
|
|
|
|
|
|
2026-10-22 09:29:56 +02:00
|
|
|
|
|
|
|
|
|
|
## 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:
|
|
|
|
|
|
|
2026-10-22 10:09:50 +02:00
|
|
|
|
- **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.
|
2026-10-22 09:29:56 +02:00
|
|
|
|
- **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.
|
2026-10-22 10:09:50 +02:00
|
|
|
|
|
|
|
|
|
|
## 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.
|