Commit graph halkyon/apps
Author SHA1 Message Date
b1cd4679be
test(privacy): a hostile instance against private studysets (ADR 0019)
The final reading of 22 Oct: rather than one reading at a time finding a
variant, a seeded test plays the instance. Each run interleaves 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 (unsigned columns changed, rows
replayed under their ids, revisions and answers pointed elsewhere,
unsigned rows planted and signed ones copied from elsewhere, key records
withheld, handed back or forged, tags linked, files withheld, settings
folded away and handed back, the register answering no), each followed by
the upkeep after a merge. After every step, against a model kept from the
person's actions alone: nothing private when written is readable in any
row kept or file stored unless the person made it so, a release sends
only the tag names shown, and Count me is on only if the person turned it
on last. 50 seeds of 40 steps (about a minute) on the real gateways over
a local database; HOSTILE_SEEDS, HOSTILE_STEPS and HOSTILE_FROM run more,
or one seed again, and a failure names its seed and every step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:16 +02:00
f658cb13da
fix(privacy): what the hostile instance found (ADR 0019)
Each was found by the hostile-instance test (next commit) and has a test
of its own:
- a write decides on its key from the records and the tree read anew: a
  studyset missing from a tree read before, or moved since, looked
  readable;
- each studyset on the way up decides on its own first: its own record,
  private or made readable (a readable studyset the instance moved under a
  private one was taken as private, so a private card moved in and was
  opened with the other tree), or the key its own row was seen sealed
  with, learned from that row before a write (a topic the instance moved
  to the top level was written readable on a device that never read it);
  one sealed under a key not here refuses the write (a device that never
  saw a studyset private, its key record withheld, wrote into it
  readable);
- settings keep the newest versions seen: 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; Count me folds off where
  any row says off;
- the upkeep reads only readable tags and settings, and a PDF read by its
  id never refuses as a write would;
- two queries passed nullable ids to `id IN`, which Serverpod refuses at
  run time: a path's step revisions and answers couldn't be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:16 +02:00
bb44a7d65d
fix(privacy): a device without the keys writes no content where the account has keys (ADR 0019)
Such a device opens no record, so every tree looks readable to it: it
wrote cards, notes, paths, steps, answers, PDFs and files into private
trees readable. Every content write now asks for its key through one place
that refuses there (keyToWriteIn, and each item's write key), before any
row is inserted; reading is unchanged. That the account has keys is kept
once this device saw key rows, so withholding them later and answering no
changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:15 +02:00
ee44ca2a44
perf(privacy): sweeps cost what a merge brought, and a rewrite holds only its tree (ADR 0019)
From the final reading of 22 Oct:
- a sweep loads only the rows not under the tree's key already, and opening
  yields to the event loop every 64 rows, so a large tree never holds the
  screen;
- a write waits only for a rewrite of its own tree, and reading waits for
  none (it only decides what is trusted, which the records settle);
- a ledger write that failed keeps its changes for the next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:15 +02:00
c9c6533afc
fix(privacy): Finish shows what it would take in, and takes in exactly that (ADR 0019)
The final reading of 22 Oct found that Finish's question said only how
many edits of each kind there were, so the person couldn't see content the
instance planted, and that taking them in sealed whatever was unsigned when
it ran, answers included without being counted. The dialog now lists each
edit with its kind and what it says (answers too), and taking them in seals
exactly the rows listed, each only while it still says what was shown; one
that came or changed after the list stays locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:15 +02:00
92ff7616a4
fix(publish): a release sends exactly the tag names the sheet showed (ADR 0019)
The final reading of 22 Oct found that the names were worked out again
after the sync that comes before publishing, so a link to a private tag
the instance merged in during that sync sent a name nobody saw going. The
sheet now hands what it showed to Publish, and the device refuses before
anything goes out when the names worked out then differ
(PublishProblem.tagNamesChanged), reading them again for the person to
look at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:15 +02:00
037abb0cc6
fix(privacy): what decides a row's key is bound into its seal (ADR 0019)
The final reading of 22 Oct 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. Every sealed payload and every signed readable
one is now bound to the row it belongs to where 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, the old dropped as nothing was released), so a
re-pointed row doesn't open 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 and an edit on two devices merge column by
column); instead each studyset on the way up keeps its own key before any
above it counts.

Also from the reading:
- whether the account has keys fails closed: any sign counts (keys open,
  the register's yes, key rows, records, sealed tags and settings), the
  register's no is never kept and it is asked again;
- a pending rewrite keeps whether it may take in unsigned rows, a sweep's
  never does, and a sweep never takes over the person's own rewrite;
- settings carry one version vector per person, rows folded away here are
  never read again, a row never seen is read only if it brings news, and
  rows without a vector (HLKS) are dropped;
- a tree stays partly readable while rows a sweep can't move remain, which
  are looked at again once more keys open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:15 +02:00
991bef47eb
feat(privacy): Finish takes in edits nobody signed once the person agrees (ADR 0019)
Finishing a partly readable studyset counts the edits a device without
the keys wrote readable while it was made private, by kind (flashcards,
notes, learning paths, PDFs, studysets), and asks: taken in, they are
sealed with the tree's key as the person's; left, they stay locked (the
short reading of 22 Oct, finding 11). In English and German. Tests on the
real gateways both ways, and of the dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:14 +02:00
75398dca2c
fix(publish): the tag names' list reads its names without catchError
The list awaits the names once as it is shown and says nothing where they
can't be had; the previous commit's test of that case failed, and the
analyzer flagged Future.catchError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:14 +02:00
9841d2d864
feat(publish): the Publish sheet lists the tag names a release sends (ADR 0019)
Before Publish, the sheet shows the names of the tags that go with the
release, which the instance learns, sorted, or that none go; none where
they can't be had, as something in the tree is private (the short
reading of 22 Oct, finding 5). In English and German.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:13 +02:00
5a61f2ea07
fix(feedback): a suggestion takes along only its own tree's sealed files (ADR 0019)
The card's, note's or step's own id goes with a suggestion, and a sealed
file goes along readable only where it is under the key of that item's
tree: a file of another private tree, named by a row the instance
planted, is left out and never uploaded readable (the short reading of
22 Oct, finding 12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:13 +02:00
ba7067a861
test(privacy): the short reading's tests catch the code before it (ADR 0019)
Run on a1adc18, every test of a fixed finding failed but two: the replayed
sealed row now comes back under its own id, as the instance would replay
it (a copy under another id never opened), and the pending make-readable
now watches the files, as the old code's sweep sealed its rows again
after opening them. The older readable record against a newer private one
passes there too: it guards what was right already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:13 +02:00
2d772a17e2
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>
2026-10-23 14:13:12 +02:00
0ecf5f1448
fix(privacy): the short reading of the reading's fixes (ADR 0019)
A rewrite or sweep changes only rows under keys whose signed ID names the
studyset rewritten (or none), so making a studyset readable never opens
another tree's rows the instance moved into it, and a sweep never seals
them under its own key; the person's own moves alone re-key rows of other
keys. An item's key is never replaced by an ended key, nor by another
studyset's while the one kept holds, and is written with before the
tree's. A pending rewrite the latest record no longer asks for is
dropped. A file that won't move is counted and owed, the tree partly
readable meanwhile, and each step of the upkeep fails alone. Settings
carry a version vector (each device's count of writes): a row is refused
as a rollback only when it brings news from no device. Sweeps look rows
up by their key and skip those known unmovable; a sync is asked for only
when something was written. Rewrites run one at a time and write back
only rows unchanged since they were read. A record is made later than
the latest known, whatever the clock. An unreadable ledger 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.

Tests (each the reading's probe): a moved card under a studyset made
readable, a sweep after a move, a replayed sealed row under an ended key,
a pending make-readable over a later private record, a file that won't
move, two devices' settings at once, an older readable record on a fresh
device, a clock behind; the withheld-record test withholds the tree's
record alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:12 +02:00
28372a35e6
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>
2026-10-23 14:13:12 +02:00
46a47d0975
fix(privacy): what the app shows and adds keeps private studysets private (ADR 0019)
The reading of 22 Oct, on the device's side. Private cards never move
into a readable studyset: the card browser asks to make it private first.
Locked studysets are left out of the move sheets and the destinations
words are added to. A studyset whose key isn't on this device says to
sign in with the password here, not that it is lost, and can still be
archived; creating one there says why it can't. The marker shows the lock
for a private studyset published before, and a shield with a warning
while something in it is still readable or unsealed, with Finish in its
dialog. The library's offer covers every top-level studyset, Recently
deleted and orphaned ones too, says what couldn't be sealed, and its
answer follows the person in their settings (schema 11).

The full-window editors carry the studyset they were opened from, and an
account with keys adds no file where none is known (a new studyset's
description takes pictures once it is made). A file no live row names is
never uploaded, and deleting a row clears what it owed. Sealed rows are
indexed by key and hash. A suggestion from a private copy uploads
readable copies of its files for the author; accepting swaps whole
`asset:` values only.

Docs: ADR 0019's "The reading (22 Oct 2026)" with the decisions, what
changed and the residual risks; "As built" and Learn's AGENTS.md brought
up to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:12 +02:00
88e79155bc
fix(privacy): privacy is a signed fact the instance can't take back (ADR 0019)
The reading of 22 Oct, against an instance that alters data. A data key's
ID now says what it is for (a studyset's key naming it, the person's key,
or a record that a studyset was made readable), and the ID is sealed with
the key and signed by the account, so the instance can't relabel a key or
make one up; the row's own columns are no longer trusted. A studyset is
private while the latest signed record about it says so, and the device
keeps the latest it saw in a ledger in the person's folder (privacy.json),
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. An item keeps
the key it was last seen sealed with unless this device moves it or a
signed record makes its tree readable.

A readable row a device holding the keys writes carries the account's
signature in place of a nonce; one nobody signed is locked wherever
privacy holds (in private trees, for items once sealed, and for tags and
settings once sealed), so the instance can't plant content, confirm a
guessed tag name by folding, or switch Count me on. Signed readable rows
from a device that hadn't heard are shown and sealed anew.

Rewrites are kept as under way until done, write the top studyset last,
hold writes into their tree meanwhile, and are finished as the data opens
and after each merge; a sweep seals what a stale device wrote. Cards moved
into a private tree are sealed with their files first; cards of a private
tree are never moved into a readable one. A private topic moved out of
its tree keeps its privacy with a key of its own. Settings carry a counter
inside their sealed payload, and a lower one than seen is refused. The
person's readable tags and settings are sealed once. A device without the
keys of an account that has them writes nothing that would be private and
reports nothing for the learner count; an anonymous person's keys kept but
unreadable are never replaced. Publishing sends only the names of the
tags the release carries. Key refusals are remembered per row.

Tests on the real gateways over a local database: a replayed readable top
row, a withheld key record, a moved parent, a planted revision, a signed
stale revision, card and topic moves, an interrupted rewrite, a settings
rollback, a device without keys, planted tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:11 +02:00
8ea2f04384
style(privacy): the server part's directives and quotes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:11 +02:00
d9b5e06618
fix(privacy): the instance's side of the reading of private studysets (ADR 0019)
An upload said to be encrypted must start as a sealed file does (HLKF,
version 1), so a readable file can't skip the PDF check by saying so. A
person's room counts the files a live asset row names and those claimed
within the week of grace, so a private studyset's rewrite near the limit
no longer fails for a week while pruning keeps the replaced files. Copy
reports are limited to 60 an hour per person. Tests: what goes with the
person, `counted` refused to anonymous people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:11 +02:00
678a3b5bde
docs(privacy): a private studyset's files and its marker, as built (ADR 0019)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:10 +02:00
9fb1ab2f6c
feat(studyset): a studyset's marker says what the instance sees of it (ADR 0019)
The developer's decision of 21 Oct, on the library's cards and the
studyset's Share in its action row alike:
- a lock: private, end-to-end encrypted;
- an open lock: readable by the instance, not published;
- a globe: published.

The lock and the open lock open one dialog saying what that means and
changing it (Make non-private; Make private, or Publish…, which opens the
Publish sheet, while it isn't private); a topic's dialog says its privacy
is its tree's. The globe opens the Publish sheet, whose status for a
studyset not published now says so ("Not published") with an open lock,
as "Private" means something else now. Texts in en and de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:10 +02:00
e368bba2dd
feat(privacy): a private studyset's files are sealed with its key (ADR 0019, D5)
- `AssetRepository` seals a file of a private studyset with its tree's key
  for its asset row before hashing it (`AssetSealing`, which `Privacy`
  does): the row's hash and size and the file uploaded are the sealed
  file's, the upload says it is encrypted, and the row's payload, sealed
  too, carries the file name and the hash of what was sealed (asset
  schema 2). A download is checked against the row's hash, opened for its
  row and checked against what was sealed. This device keeps the sealed
  file to upload and the opened one, by its own hash, for what reads files
  by path.
- The same file is a row per key: in a private and a readable studyset, or
  two private trees, it is two.
- Every place adding a file names its studyset: the editors' pictures and
  sounds (`StudysetFiles`, which every screen of a studyset and the PDF
  reader set), PDFs and screenshots, Anki imports, copies and their updates
  (a copy's top studyset is made first, so its files take its key), and
  accepted suggestions (by their item's tree).
- Making a tree private or not, or moving a topic into another tree, moves
  its files: each under another key is added anew under the tree's, the
  content names the new row (whole `asset:` values and a PDF's file, never
  text), and the old row is blanked and deleted once nothing else names it.
- Tests: a private studyset's file never uploads readable; a sealed file
  moved to another row, or under a key not open here, doesn't open; a
  changed file is refused by its hash; one row per key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:10 +02:00
dcdc9c1542
docs(privacy): private studysets as built (ADR 0019)
Learn's AGENTS.md says how private studysets work (keys, the envelope,
locked rows, rewriting a tree) and what changed for the connector,
publishing, uploads and the learner count; ADR 0019 records what was built
otherwise than decided or not yet (a private studyset's files); the
halkyon_app README describes the content cipher and device keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:09 +02:00
9cdd4859d1
feat(library): offer once to make every studyset from before private (ADR 0019, D4)
New studysets are private; those from before stay readable until the
person chooses. The library offers once, on this device, where it can
encrypt and some top-level studyset isn't private: Make all private, or
Not now; either answer is remembered (`private_offer` in the person's
folder) and the offer goes. Each studyset's menu changes it later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:09 +02:00
0e10afcc68
feat(studyset): make a studyset private or not, and what a locked one shows (ADR 0019)
- A top-level studyset's options make it private, with everything inside
  it, or non-private again, after a dialog saying what changes and that
  the instance's backups keep readable copies until they expire; a device
  without the account's keys says how to get them there.
- A locked studyset (its key doesn't open here) is titled Locked, says
  why when opened, and offers only Delete.
- Publishing names the person's tags as the device reads them, and says
  what to do when the studyset is private or a tag has no name.
- The export sheet says an archive is readable by whoever has the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:09 +02:00
2f169ae899
feat(explore): the device tells the instance whether to count the person, and what their copies come from (ADR 0019, D3)
Count me stays in the person's settings, which follow them to every device
and are now encrypted; after each sync the device mirrors it to the
instance (`learningCount.setCounted`) and, only while it is on, reports
which publications their copies come from, topics with them
(`reportCopies`). Taken back, the instance forgets the copies. Only people
with an identity report, as only they are counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:09 +02:00
64fdfa091d
feat(privacy): every synced payload sealed and opened through one envelope (ADR 0019)
- `ContentKeys` opens the person's data keys (`content_key`) with their
  account's keys on the device, an anonymous person's device keys made as
  their data first opens; it makes a private studyset's key and the
  personal key. Without the account's keys nothing opens and nothing new
  is private (D8).
- `PayloadEnvelope` is the one place payloads are sealed and opened, each
  for its table, row and column, so a payload moved to another row never
  opens; one that doesn't open is `Locked`, never half-read.
- `Privacy` gives each gateway the key of the row's tree (its top-level
  studyset's), and rewrites a tree under another key, or readable, in
  place (`rekeyTree`).
- The studyset, card, note, learning path, path answer and PDF gateways
  seal with their tree's key and open what they read: a new top-level
  studyset is private where the device holds the keys, a new one inside a
  tree takes its privacy, and one moved into another tree is rewritten
  under that tree's key. Tags and settings are sealed with the personal
  key; a tag or settings row that doesn't open here is left out, so
  nothing folds or deletes it. A studyset whose key doesn't open shows as
  locked (`Studyset.isLocked`); other rows that don't open read as blank.
- Every payload codec round-trips sealed, and refuses another row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:08 +02:00
a1c5ac3f86
feat(privacy): the instance's side of private studysets (ADR 0019)
- `content_key`, a synced table in the person's space, keeps their data keys
  sealed to their account (a private studyset's, their personal one);
  `learning_path_answer` gains `keyId` and `nonce` for answers in private
  studysets. Additive only, on the server and the device.
- Publishing refuses a private studyset, and a tree holding a private row
  (`PublishRefusal.private`); a private studyset elsewhere no longer breaks
  publishing a readable one. Encrypted tags are named as the device names
  them (`tagNames`): a name for a tag that isn't the person's, or an
  encrypted tag of the release without one, refuses it
  (`tagNamesMismatch`); a readable tag keeps its own name.
- The connector leaves private studysets out and says how many there are.
- The learner count reads no payload any more: Count me is kept by the
  instance (`counted_learner`) and the device reports which publications
  its copies come from (`learning_copy`, accepted only while counted, each
  report replacing the last); only the reporter's own studysets count.
  Deleting a person deletes both.
- An upload says whether its file is encrypted: only a readable one over
  20 MB is checked to be a PDF, and an encrypted one may be 4 KiB larger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:08 +02:00
24ccf2a0ab
fix(todo): what failed on the account sheet shows on it
The sheet holds snack bars of its own (`hasSnackBars`), so a failed
deletion, sign-out or reading of the account says so on it rather than
behind its barrier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:07 +02:00
3994257044
docs(auth): GitHub is offered to nobody where passwords are (ADR 0017)
ADR 0017's rules as they stand, halkyon_app's README and Learn's
AGENTS.md: where an instance offers passwords, the apps offer no Add GitHub
and no hint on adding it, an anonymous person's Sign in with GitHub reaches
only the account someone has, and every refusal says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:07 +02:00
f3b263a4fb
fix(profile): no Add GitHub where people sign in with a password
Someone identified was offered Add GitHub wherever GitHub signed people in,
and on an instance offering passwords the browser round trip ended in
"Couldn't sign in with GitHub.", as the instance adds GitHub to nobody there
(ADR 0017, D2; the end-to-end test of 21 Oct). The profile now shows the
GitHub row there only to whom it signs in already, and an anonymous
person's Sign in with GitHub, which still reaches the account someone has,
says why it didn't: a GitHub account nobody has, with the password beneath
in its place, or one that signs in with its password now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:06 +02:00
19aabb0266
fix(app): GitHub's refusals say why, and no hint adds GitHub where passwords are offered
Where an instance offers password sign-in, GitHub is added to nobody (ADR
0017, D2), yet the start screen still said "then add GitHub" after a GitHub
account nobody has, and every refusal but an unknown account read
"Couldn't sign in with GitHub." (the end-to-end test of 21 Oct).

- The gateway tells from one read of the basics whether GitHub signs in and
  whether it is added to people (`gitHubOffer`, in place of
  `gitHubClientId`); the GitHub bloc keeps it (`Ready.linking`) for the
  profile, with why an attempt failed (`Ready.problem`).
- The instance's `usePassword`, `deprecated` and `ended` become problems of
  their own (`gitHubSignInProblemOf`), worded by `gitHubProblemText` and on
  the start screen; an anonymous person refused a link is told the GitHub
  account is nobody's and offered the password instead.
- Where passwords are offered, the start screen offers an account to create
  in place of the app's hint on adding GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:06 +02:00
90e29c2f4c
test(app): Learn's profile expects the day a GitHub-only account goes
The text for an account that signs in with GitHub alone now names the
deletion 30 days after GitHub's end (f7a3251); Learn's test of its
profile looked for the words before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:06 +02:00
a3b70175f6
docs(auth): the short reading of the fixes (ADR 0017)
ADR 0017 gets "The short reading": its findings, the developer's four
decisions of 21 Oct, and what changed. "The rules as they stand" says a
reset takes effect 14 days after it was asked at the latest, that a member
keeps the session a reset signed in and deletes nobody while the register
is out of reach, and no longer claims every secret is kept out of every
capture: Android holds them back, Windows from 10 2004, macOS from
screenshots but not ScreenCaptureKit on macOS 15 and later, iOS covers
the app switcher's picture alone, Linux nothing. The module's and
halkyon_app's READMEs and Learn's AGENTS.md follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:06 +02:00
012f2375a5
fix(app): the short reading's nits on the device
- Deleting an identified account whose security can't be read (the
  instance out of reach) says so and deletes nothing; it went on to a
  deletion by the session alone, refused for an account with a password,
  and the person saw a spinner, then nothing. A refusal still leaves the
  session's own deletion.
- InstanceChoice's sharesStorage is required, so no app leaves it out and
  removes a folder another instance uses.
- A PasswordUnlock forgotten is spent: its secrets read again fail
  (StateError) rather than make a proof or a wrapping of zeros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:05 +02:00
7ac6eb75b4
fix(app): Windows keeps secrets out of captures by its real version
Before Windows 10 2004, SetWindowDisplayAffinity takes
WDA_EXCLUDEFROMCAPTURE without failing and acts as WDA_MONITOR, so the
fallback on a failure never ran and its comment was wrong (the short
reading). Both apps' runners now choose by the real version, RtlGetVersion
(GetVersionEx tells what the manifest claims): WDA_EXCLUDEFROMCAPTURE from
build 19041 on, WDA_MONITOR before. Checked for syntax only: nothing here
builds for Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:05 +02:00
290f584329
fix(app): a saved secret is 0600 on Apple's Arm too, and macOS saves at all
The short reading's H1: open(2) takes its mode among variable arguments,
which Apple's Arm passes on the stack, so bound with three fixed ones it
read the mode from whatever the stack held: a phrase or recovery codes
saved on an Apple silicon Mac could be readable by everyone. It is bound
as variadic now (VarArgs), and every file written is made 0600 through its
descriptor (fchmod), checked.

M1: macOS's sandbox lets an app reach the file the person chose and
nothing beside it, so the file made beside it was refused and nothing
could be saved; Learn's sandbox allowed reading chosen files alone, so its
exports never saved on macOS either. There, writePrivately now writes the
chosen file itself (decided by the developer on 21 Oct): opened without
following a link or waiting on a pipe and without truncating, refused with
nothing written unless fstat finds a file of the own user's, then made
0600 and emptied. Both apps' entitlements grant reading and writing the
files the person chooses. Linux keeps the new file renamed over it.

The reading's nits: the buffer is wiped whatever happens, an interrupted
call is made again (EINTR), the file made beside a chosen one is named
apart from it so a name as long as a folder takes still saves, the folder
is flushed after the rename, and Linux reads a file's type and owner with
statx, alike on every architecture, where __lxstat's version differed
(riscv64's was wrong). The test of another user's file is skipped as root.
The in-place path is tested on Linux; none of it has run on a Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:04 +02:00
8d6c54ef22
docs(auth): reading the full reading's fixes (ADR 0017)
ADR 0017 gets "Reading the fixes (21 Oct 2026)": each finding and what
was done, and "The rules as they stand" brought up to date (resets take
effect once 72 hours outside hold-outs passed and lapse 30 days after
they were asked; GitHub-only accounts go 30 days after the end; a member
takes the register's moment by its own clock; nothing secret copied on
the device). The module README and Learn's AGENTS.md say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:02 +02:00
b9bf9813c5
feat(app): secrets kept out of screenshots on macOS and Windows too
The reading of the full reading's fixes noted that macOS and Windows can
keep a window out of screen captures, which SecretScreen didn't ask.

PlatformScreenGuard now asks the app's window on Android, macOS and
Windows (asksOn), and both apps' runners answer app.halkyon/screen_guard,
counting what shows: macOS's MainFlutterWindow sets sharingType = .none,
Windows' FlutterWindow WDA_EXCLUDEFROMCAPTURE (Windows 10 2004 on;
before, WDA_MONITOR, black in captures). Neither runner was built here
(Linux): to be tried on each system.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:01 +02:00
16ad925095
fix(app): http:// and https:// on one host are one account on the device
The reading of the full reading's fixes found that the device keeps
http://host/ and https://host/ in one place (folder, session key, keyring
prefix): someone with a stored http://host/ who added https://host/ and
then removed the first deleted the second's folder, session and keys while
it was in use, losing unsynced changes, and an anonymous account for good.

The places keep the names installs made (InstanceStorage.sharesWith says
which addresses share one): choosing an address the device keeps where a
known one is puts it in that one's place in the list, and removing an
address forgets nothing another known one uses. The build's own instance
keeps its place apart. Learn and Todo pass the storage's rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:13:01 +02:00
f9880e6989
fix(auth): GitHub-only accounts go 30 days after GitHub's end, on the register alone, one failure holding back none
The reading of the full reading's fixes found that a member, which keeps
no addresses, would delete everyone it holds with a GitHub ID as if
GitHub were their only way in (D1); that each deletion delivered every
pending notice, so with a member hanging the daily run grew quadratic,
and one failing person aborted it, forgetting no GitHub ID (D2); and that
a GITHUB_SIGN_IN_ENDS set wrong deleted data at the next daily run.

forgetGitHubAccounts now does nothing on a member and waits 30 days after
the end (gitHubAccountsKeptAfterEnd), keeping the accounts and IDs while a
wrong date can be put right; deletes each GitHub-only account on its own,
a failure logged by type, that person's GitHub ID kept for the next run;
and tells the members once, after all of them (deletePerson's tellMembers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:59 +02:00
274b796faa
fix(auth): a hold-out moves a reset on only by its own time, and a reset lives 30 days at most
The reading of the full reading's fixes found that every refusal at the
address's ceiling moved a reset to 72 hours after it (A1): someone who
knows only the address kept the owner's own reset from ever taking effect
with one burst every three days, and kept a forgotten one alive (A2).

Now a hold-out that begins before a reset takes effect moves it on only by
the time it holds the owner out and no hold-out counted before did
(account_reset.heldOutUntil, under a row lock), so the owner gets 72 hours
outside hold-outs in total; and every reset not carried out lapses 30 days
after it was asked (resetLivesAtMost), whatever moved it, besides a week
after it took effect.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_reset" ADD COLUMN "heldOutUntil" timestamp without
time zone; nullable, existing rows counted from now on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:59 +02:00
a72b96d628
docs(auth): the device side's rest of the full reading (ADR 0017)
ADR 0017's full-reading part says what the device's fixes did: the
clipboard, plain HTTP refused but on this machine, sheets never left busy,
what Save leaves, secrets out of debug logs, keyboards, screenshots and
backups, memory, and the real cryptography against the register's checks.
halkyon_app's README says what an app must add (its activity's screen
guard), Learn's AGENTS.md the HTTPS rule for instances.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:58 +02:00
f3c3f7fb51
fix(app): secrets are kept out of screenshots and the recent apps
The recovery phrase, recovery codes and an authenticator's QR code and
key now show inside a SecretScreen: on Android it holds FLAG_SECURE while
it shows (through a channel each app's MainActivity answers, counting
holds, as several may show at once; ScreenGuard.kt), so screenshots,
screen recordings and the recents view don't get them; on iOS, which has
no such flag, it covers them while the app isn't in front, so the app
switcher's picture doesn't hold them (Dart-side, not checked on a device).
Desktops do neither: a window loses focus whenever its person turns to a
password manager. Todo's debug APK builds with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:58 +02:00
5067d11c18
fix(app): no state logs an authenticator's key, codes or the address
Equatable's own text lists every field, and a debug build's bloc observer
logs every state, so an authenticator's otpauth URI with its secret, the
recovery codes and the address went into the log. The password states
(signing in, getting back in, adding an authenticator, a step of the
account section) and AuthenticatorSetup now say only where they stand, as
the setup and new-phrase states did; Learn's observer takes a writer so
its test holds what it logs to that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:57 +02:00
8ec2743bfa
docs(auth): the full reading, and the rules as they stand now (ADR 0017)
ADR 0017's "Full reading (21 Oct 2026)": each server finding and what was
done (H1, M1, M2, M3, L1, L4, L5, L6, L7, L10), those accepted with their
reasons (L2, L3, L8, L9), and the device's M1, the owner's proof bound to
its operation and the whole base URL, still to do. "The rules as they
stand" brought up to date; the module README and Learn's AGENTS.md too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:54 +02:00
5eb74639b6
fix(auth): GitHub-only accounts go at GitHub's end; only the anonymous add an address
The full reading's M1 and L6: once GitHub IDs were forgotten at
GITHUB_SIGN_IN_ENDS, an account that had signed in with GitHub alone
counted as anonymous, so a stolen session and any mailbox gave it a first
password and authenticator; and such accounts, with no way in left, were
never pruned. Now:

- at the end, the daily run that forgets the GitHub IDs first deletes the
  accounts that sign in with GitHub alone (no address, so never a
  password), with everything they have, members told (deletePerson); the
  app tells them for the three months before that their account goes then
  unless they set up a password (passwordSectionGitHubEndsDeletes, en/de);
- password.set adds an address without GitHub only for someone anonymous
  where the session lives (anonymousHere, which a member tells the
  register, as it tells freshGitHubSignIn); anyone else is refused before
  any code is used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:52 +02:00
4d1cd2045c
fix(auth): codes after the recovery phrase count apart from the password's
The full reading's M3: whoever knows the password made the account's codes
wait, and that wait refused the phrase's owner their reset too, so they were
held out of both without end. Codes now count per account and per way they
came (SecondFactorPath: after the password, after the phrase), each with
its own waits; a code accepted either way clears both.

Migration (module, Learn's and Todo's servers; Learn's device schema
rebuilds the server-only table):
  DROP INDEX "second_factor_failures_person_idx";
  ALTER TABLE "second_factor_failures" ADD COLUMN "path" text NOT NULL
    DEFAULT 'password'::text;
  CREATE UNIQUE INDEX "second_factor_failures_person_idx" ON
    "second_factor_failures" USING btree ("authUserId", "path");
Existing rows are the password's, unique per account, so unique per
account and way too.

Also the hosts' generated future calls, for the pruning's doc comment the
lapsed resets' commit changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:52 +02:00
43fcc8aac2
fix(auth): a reset not carried out lapses a week after it took effect
The full reading's M2: a reset of the authenticators asked and forgotten
left the account to the password alone for good, and an account's reset
could be carried out a year after it was asked. Now a reset that took
effect and wasn't carried out lapses a week later, as never asked: the
password alone no longer signs in, a code still does, and asked again it
waits anew with a new mail. A reset of the authenticators carried out never
lapses (the password alone signs in until one is confirmed). The daily
pruning forgets lapsed rows (forgetLapsedResets).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:51 +02:00
5e7d7f4e36
feat(auth): a session ended for its wrong proofs warns its owner
The tenth wrong owner proof in a row ends a session (the third
reading); someone holding that session tried the password or its
codes. The register now keeps the moment with the account's password
(`account_password.sessionEndedForProofsAt`): the account's sign-in
ways and its next password sign-in carry it, until the owner dismisses
it (`password.dismissSessionEndedWarning`, their session alone: it
hides a warning and secures nothing) or sets a new password. A member
ending its own session tells the register
(`noteSessionEndedForProofs`); a register out of reach leaves the
owner unwarned, the session ended all the same.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "sessionEndedForProofsAt"
timestamp without time zone; nullable, so every row keeps its values
and has no warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:51 +02:00