docs(privacy): the reading of private studysets (ADR 0019)

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 09:29:56 +02:00
commit 28372a35e6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 93 additions and 6 deletions

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. - **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. - **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. - **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, `PrivateOfferStore`). 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 tree's key: its top-level studyset's `keyId`): 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, 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. - **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`).
- **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. - **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. - **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. - **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.

View file

@ -132,8 +132,10 @@ millisecond), its table and the columns it changed.
new rows in its content (whole `asset:` values and a PDF's file, never new rows in its content (whole `asset:` values and a PDF's file, never
text) and blanks the old rows nothing else names. text) and blanks the old rows nothing else names.
- **The marker** (the developer's, 21 Oct): on the library's cards and the - **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 studyset's Share alike, a lock for private (published before or not), a
the instance and not published, a globe for published. The locks open 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 one dialog saying what that means and changing it (and, while not
private, leading to the Publish sheet); the globe opens the Publish private, leading to the Publish sheet); the globe opens the Publish
sheet. sheet.
@ -143,9 +145,8 @@ millisecond), its table and the columns it changed.
instance keeps the setting, as D2 asks, but the person's choice is still instance keeps the setting, as D2 asks, but the person's choice is still
made where it was. made where it was.
- **Tag names:** the instance refuses a name sent for a tag that isn't the - **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 and a release whose encrypted tag has none; since the reading of
person's tags the release doesn't carry are ignored rather than refused, 22 Oct the device sends only the names of the tags the release carries.
as the device sends all its tags' names.
- **Locked rows:** a studyset whose key doesn't open shows as locked; its - **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 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 row that doesn't open is left out entirely, so nothing folds or deletes
@ -154,3 +155,89 @@ millisecond), its table and the columns it changed.
column, and signed by the account; the signature goes before the column, and signed by the account; the signature goes before the
ciphertext in the payload column. 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:
- **A fresh device** that never saw a studyset private, while the instance
withholds its records and hands back an old readable row, can be made to
write readable there. Nothing on such a device says otherwise; 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.