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

ADR 0019 gets "The final reading (22 Oct 2026)": what decides a key is
bound into what is sealed or signed, the walk up the tree, a device
without the keys writing nothing, settings' newest versions and Count me
folding off-wins, publishing and Finish as shown, the rewrites; the
hostile-instance test, its invariant and what it found, on the final code
and on the code before; the residual risks as they stand. Learn's
AGENTS.md gets the rule that every column deciding a key is bound into
what is sealed or signed, and the test.

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 13:05:43 +02:00
commit 3bb90a725a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -287,3 +287,113 @@ the real gateways; each probe is a test now.
Each test of a fixed finding was run on the code before (`a1adc18`) and
failed there; one of an older readable record against a newer private one
passed there too, as it guards what was right already.
## The final reading (22 Oct 2026)
A third reading found that an item learned its first key from any sealed
row pointed at it (the instance pointed a revision of private B at a card
of private A; the card's next edit was sealed under B's key, and making B
readable opened it), and that a device without the keys trusted a "no" from
the register forever. The readings kept finding variants, each closing one
path while most columns stayed unsigned, so the developer chose to bind
what decides keys and to test against a hostile instance:
- **What decides a key is bound into what is sealed or signed.** A sealed
payload's additional data, and a readable row's signature, name the row it
belongs to wherever that decides its key: a revision's card, note or
step, an answer's step, a path's or a PDF's studyset (sealed format v2;
nothing sealed before was released). A row the instance points at another
item doesn't open there and reads as locked, and an item learns its key
only from its own rows, before any write decides on one. A studyset's
`parentId` isn't bound: a move on one device and an edit on another merge
column by column, and the row would lock for nobody's fault. Instead each
studyset on the way up decides on its own before any above it: its own
record (private, or made readable) or the key its own row was seen sealed
with, learned from that row before a write if not known yet; one whose
row is sealed under a key not here is private, and a write into it is
refused.
- **Before a write decides on a key,** the device reads the records and the
tree anew: a studyset missing from a tree read before, or moved since,
would look readable.
- **A device without the account's keys,** on an account that has them,
writes no content at all: it opens no record, so every tree would look
readable to it. That the account has keys is any sign, never taken back:
the keys open here, the register's yes, key rows (remembered once seen),
records or sealed tags and settings seen; the register's answer comes
through the instance unsigned, so its no outweighs nothing and is asked
again.
- **Settings** keep the newest versions seen (each row's vector that no
other seen is newer than): a row older than any of them, or than another
read with it, isn't read, so withholding a newer row and handing back an
older one rolls nothing back; rows folded away here are never read again.
Count me folds off where any row says off, so an instance handing back a
genuine older on can't switch it on (the person switches it on again at
worst). The short reading's `HLKS` counter is gone.
- **Publishing** sends exactly the tag names the sheet showed; names that
changed in the sync before Publish are refused on the device.
- **Finish** lists what it would take in, each edit with what it says, and
takes in exactly those rows, each only while it still says what was
shown.
- **Rewrites:** a pending one keeps whether it may take in what nobody
signed (only the person's own make-private of a readable tree may; never a
sweep), and a sweep never takes over the person's own. A sweep reads only
rows not under the key already, yielding now and then; a write waits only
for a rewrite of its own tree, reading for none. A tree stays partly
readable while rows a sweep can't move remain, which are looked at again
once more keys open.
- **A damaged `privacy.json`** is kept aside; once the keys open, the device
goes on as a fresh one would, with every residual risk of a fresh device.
**The hostile-instance test** (`test/core/privacy/hostile_instance_test.dart`)
holds the invariant: seeded runs interleave what the person does on a
laptop, a phone and a device without the keys (studysets, topics, cards,
notes, paths, steps, answers, PDFs, files, moves, making private or readable,
tags, Count me, Finish, publishing, reading back) with what an instance that
alters rows does to the local database, as a sync would bring it (changing
any unsigned column, replaying older versions under their ids, pointing
revisions and answers elsewhere, planting unsigned rows and signed rows
copied from elsewhere, withholding, handing back or forging key records,
linking tags, withholding files, folding away and handing back settings,
answering the register no), each followed by the upkeep after a merge.
After every step it checks, against a model kept from the person's actions
alone, that no marker of content private when written is readable in any
row the device keeps or file it stores unless the person made it readable,
that a release sends only the tag names shown, and that Count me is on
only where the person turned it on last, on the device they did it on (any
other can be held at an earlier record, below). 50 seeds of 40 steps run
with `tool/check.sh` (about a minute); `HOSTILE_SEEDS` and `HOSTILE_STEPS`
run more. Suggesting from a copy isn't among its actions (a test of its own
covers it).
Run on the code before the final reading (`6179c98`), 117 of 200 seeds
broke the invariant: 73 by H2's class, content a device without the keys
wrote readable (seeds 0, 2, 3, …); the rest by what is listed below
(writes on a stale tree, a rollback of Count me, release names not shown).
H1 itself wasn't reached by chance there, in 1,000 seeds without the device
without the keys, nor on the final code with only the binding taken out:
its chain is five particular steps long, and on the old code the more
common faults end each seed first. Its own test fails on the old code.
Writing it found, beyond H1 and H2: a write deciding on a key from records
and a tree read before; a topic the instance moved to the top level, its
own row never read here; a studyset whose key record was withheld from a
device that never saw it private; a studyset made readable by its own
record taken as private once the instance moved it under a private one;
settings rolled back by withholding a newer row; and two queries that
failed at run time (`id IN` with nullable ids: a path's step revisions and
answers couldn't be read). Each has a test of its own now.
Residual risks, as they stand:
- **Any device can be held at any earlier genuine record.** A fresh device
shown an old readable version of a studyset, its records withheld, writes
readable there; so does a device whose `privacy.json` was lost. A device
the instance keeps from a later Count me off goes on reporting copies;
the device that turned it off never does.
- **First sealing of tags and settings** on each device trusts what is
readable then.
- **Old sealed content handed back** shows as it was (R2), except settings.
- **Structure and rhythm** stay readable (D7).
- **Tag links aren't signed:** the instance can link a private tag to an
item of a studyset being published; the Publish sheet shows every name
before it goes.