fix(privacy): publishing and suggestions after the short reading (ADR 0019)

Publishing is refused on the device where anything in the tree is sealed,
before any tag's name goes out: the tree and the tags' links are the
instance's to change. A suggestion leaves sealed files out, as the
instance could name another private tree's file through a planted row.
Docs: ADR 0019's "The short reading (22 Oct 2026)", and the general
residual risk that any device can be held at an earlier genuine record;
Learn's AGENTS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-22 10:09:50 +02:00
commit 2d772a17e2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -86,7 +86,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = readable, ADR 0002 rule 6; set for what a data key seals, ADR 0019) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Private studysets** (ADR 0019, decided 2026-10-21): a top-level studyset is private or not, with everything in its tree; new ones are private wherever the device holds the account's keys (an anonymous person's device makes key pairs of its own, which a password later wraps as the account's: `AccountCrypto.newDeviceKeys`, `makePassword`), copies from Explore too, and a studyset's options switch it (`setStudysetPrivacy`; the library offers once to make those from before private, every top-level one, its answer following the person in their settings, `privateOfferAnswered`, settings schema 11). Its data key, and the person's own key for their tags and settings, live in the synced `content_key`, sealed to the account (`ContentKeys`, `core/privacy/`). Every synced gateway seals and opens through one envelope (`PayloadEnvelope`, through `Privacy`, which knows each row's key: never from a column the instance controls, see below): XChaCha20-Poly1305 for the row's table, id and column, so a payload moved to another row never opens, the payload holding the author's signature before the ciphertext; a path answer's `answer` holds its sealed form in base64. A new row's id is made on the device (`Privacy.newId`, UUID v7) so it is sealed for it before it is written. What doesn't open is `Locked`: a studyset shows Locked with Delete alone, its other rows read as blank, and a tag or settings row is left out so nothing folds or deletes it. Making a tree private or not, or moving a topic into another tree, rewrites its rows in place (`Privacy.rekeyTree`). The shape, size and rhythm of studying stay readable (columns, and the sync engine's change times), as the privacy page lists. **A private studyset's files** are sealed with its tree's key for their asset row before they are hashed (`AssetRepository` through `AssetSealing`, which `Privacy` does): the row's hash, the size and the upload (`encrypted`) are the sealed file's, its sealed payload names the file and the hash of what was sealed (asset schema 2), and the device keeps the sealed file to upload 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 (`add`'s `studysetId`; the editors read `StudysetFiles`, which `_inStudyset` and the PDF reader set; accepted suggestions go by their item, `addToItem`), and rewriting a tree adds its files anew under its key, renames them in its content (whole values only) and blanks the old rows nothing else names. **A studyset's marker** (decided 2026-10-21), on the library's cards and its Share alike: a lock for private (published before or not), a shield with a warning while something in it is still readable or unsealed (Finish in its dialog), an open lock for readable and not published, a globe for published (`visibilityIcon`); the locks open the privacy dialog (`showStudysetPrivacy`, which also leads to publishing while not private), the globe the Publish sheet. **Against an instance that alters data** (ADR 0019's reading of 22 Oct): a key's sealed ID says what it is for (`ContentKeyId`: a studyset's key naming it, the person's, or a signed record that a studyset was made readable), so a studyset is private while the latest signed record says so, and the device keeps the latest it saw, every item's last key, the settings' counters and unfinished rewrites in `privacy.json` in the person's folder (`PrivacyLedger`, never synced); a tree's key is the nearest studyset's with a record, an item never written with a weaker key than it had unless this device moved it. A device holding the keys signs the readable rows it writes (the account's signature in `nonce`); a readable row nobody signed is `Locked` wherever privacy holds (`Privacy.openItem`). Rewrites are kept as under way, write the top studyset last and hold writes into their tree; `Privacy.maintain` (as the data opens, after each merge) finishes them and seals what a device that hadn't heard wrote. Cards of a private tree never move into a readable one (the app asks to make it private first, `PrivacyRefused`); a private topic moved out of its tree keeps a key of its own. A device without the keys of an account that has them writes nothing that would be private (`keysMissing`). The full-window editors carry their studyset (`editRichText`), and where none is known an account with keys adds no file (`StudysetFiles.scopeOf`).
- **Private studysets** (ADR 0019, decided 2026-10-21): a top-level studyset is private or not, with everything in its tree; new ones are private wherever the device holds the account's keys (an anonymous person's device makes key pairs of its own, which a password later wraps as the account's: `AccountCrypto.newDeviceKeys`, `makePassword`), copies from Explore too, and a studyset's options switch it (`setStudysetPrivacy`; the library offers once to make those from before private, every top-level one, its answer following the person in their settings, `privateOfferAnswered`, settings schema 11). Its data key, and the person's own key for their tags and settings, live in the synced `content_key`, sealed to the account (`ContentKeys`, `core/privacy/`). Every synced gateway seals and opens through one envelope (`PayloadEnvelope`, through `Privacy`, which knows each row's key: never from a column the instance controls, see below): XChaCha20-Poly1305 for the row's table, id and column, so a payload moved to another row never opens, the payload holding the author's signature before the ciphertext; a path answer's `answer` holds its sealed form in base64. A new row's id is made on the device (`Privacy.newId`, UUID v7) so it is sealed for it before it is written. What doesn't open is `Locked`: a studyset shows Locked with Delete alone, its other rows read as blank, and a tag or settings row is left out so nothing folds or deletes it. Making a tree private or not, or moving a topic into another tree, rewrites its rows in place (`Privacy.rekeyTree`). The shape, size and rhythm of studying stay readable (columns, and the sync engine's change times), as the privacy page lists. **A private studyset's files** are sealed with its tree's key for their asset row before they are hashed (`AssetRepository` through `AssetSealing`, which `Privacy` does): the row's hash, the size and the upload (`encrypted`) are the sealed file's, its sealed payload names the file and the hash of what was sealed (asset schema 2), and the device keeps the sealed file to upload 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 (`add`'s `studysetId`; the editors read `StudysetFiles`, which `_inStudyset` and the PDF reader set; accepted suggestions go by their item, `addToItem`), and rewriting a tree adds its files anew under its key, renames them in its content (whole values only) and blanks the old rows nothing else names. **A studyset's marker** (decided 2026-10-21), on the library's cards and its Share alike: a lock for private (published before or not), a shield with a warning while something in it is still readable or unsealed (Finish in its dialog), an open lock for readable and not published, a globe for published (`visibilityIcon`); the locks open the privacy dialog (`showStudysetPrivacy`, which also leads to publishing while not private), the globe the Publish sheet. **Against an instance that alters data** (ADR 0019's reading of 22 Oct): a key's sealed ID says what it is for (`ContentKeyId`: a studyset's key naming it, the person's, or a signed record that a studyset was made readable), so a studyset is private while the latest signed record says so, and the device keeps the latest it saw, every item's last key, the settings' counters and unfinished rewrites in `privacy.json` in the person's folder (`PrivacyLedger`, never synced); a tree's key is the nearest studyset's with a record, an item never written with a weaker key than it had unless this device moved it. A device holding the keys signs the readable rows it writes (the account's signature in `nonce`); a readable row nobody signed is `Locked` wherever privacy holds (`Privacy.openItem`). Rewrites run one at a time, are kept as under way, write the top studyset last, hold writes into their tree, and touch only rows under no key, the target key or one naming the studyset rewritten (the person's own moves excepted); `Privacy.maintain` (as the data opens, after each merge) finishes them and seals what a device that hadn't heard wrote. Cards of a private tree never move into a readable one (the app asks to make it private first, `PrivacyRefused`); a private topic moved out of its tree keeps a key of its own. A device without the keys of an account that has them writes nothing that would be private (`keysMissing`). The full-window editors carry their studyset (`editRichText`), and where none is known an account with keys adds no file (`StudysetFiles.scopeOf`).
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). **What is read is kept until it changes** (decided 2026-10-17; on a copy of a real library, 8,000 cards and 21,000 reviews, every screen's counts took over a second after each change, now about 65 ms once): `CardRepository` keeps each studyset's cards and every card's stamp until a card changes, `ReviewRepository` the whole log once many cards' reviews are asked for, updated by this device's writes and read anew after a sync brings others', and `StudysetProgressRepository` works out every studyset's progress once for the library, every studyset open and its topics. What everyone follows (the studysets, the settings, the tags, the progress) is a `LatestValue` (`core/async/`): a screen starting to follow gets the value last read, and only the first has it read, rather than every screen opening having it read again for every screen open; while nobody follows, nothing is kept or read. Work asked for again while it runs runs once more after it, not once each (`CoalescingRunner`, halkyon_core: syncs, uploads, working progress out), and a repository's watch reads so for each listener (`watchReads`); never write that loop by hand again. A read begun before a change never fills a cache (each counts the changes), and a cache keeps values, never futures. A test reading right after a change that reaches the repository another way (a fake remote change, a bloc's write still under way) lets the event queue run first. `SyncService` (halkyon_app) syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), and the studied studyset's limits hold for everything inside it together, as Anki's do for a deck with its subdecks (decided 2026-10-17: a language's twenty topics started 20 new cards a day each), in the order the studied studyset's options ask for (reviews first, mixed, new first). **Exam plans** (`feature/studyset/model/exam_plan.dart`, decided 2026-10-14): a studyset plans for exams (`examPlans` in its payload, schema 7: each a name, the topics it takes whole by their ids, the tags whose cards it takes by their names), its author's published with it (the copier and the updater point the topics at the copy's studysets), and plans are offered from level tags too (`automaticExamPlans`: JLPT, HSK, KRDict's levels, each taking the levels below it, ids `auto:…`). The person's choice (`exam`: the plan's id and the exam's day, `YYYY-MM-DD`) is theirs alone, never published, like a PDF's deadline. `ExamCoverage` reads what a plan covers (its topics' studysets with their paths and PDFs, its tags' cards and learning paths anywhere in the studyset; a path's tags offer automatic plans as a card's do, decided 2026-10-17); the Study plan shows how far the person has come with it and the pace (`examPace`: what is new spread so that it is learnt a week before the exam, or halfway there when it is closer; new cards counted as the day began), and Study for the exam (`StudyFocus.exam`) studies what it covers alone, as one group whose new cards a day are that pace, reviews unlimited; once the exam's day is over, neither shows, and a session for it studies as usual. The library's and the topics' counts and badges add up the same way, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. Deleting a card deletes its reviews.