Commit graph halkyon/apps/learn/halkyon_learn_flutter
Author SHA1 Message Date
15326f675e
test(privacy): Count me is held to its last choice on the device that made it
Seed 77 found the phone showing Count me on after the laptop turned it
off: the instance withheld the laptop's row from the phone and handed back
the phone's own older one. That is a device held at an earlier genuine
record, ADR 0019's residual risk; what holds is that no device goes back
behind what it saw itself, which is what the test now checks.

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
bf6864682c
test(privacy): the hostile instance points sealed revisions across trees, as H1 did
Half of its card revisions pointed elsewhere are sealed ones pointed at a
card of another tree, the final reading's H1, which a choice among all
revisions and cards made rare.

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
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
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
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
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
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
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
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
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
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
f0413531ff
fix(auth)!: a first password takes a fresh proof of the account's way in
The fourth reading's first finding: password.set took a session alone,
so whoever took one made a GitHub-only account, or an address account
without a password, theirs for good (their address or password, their
authenticator; GitHub then refused the owner).

- An account with an address proves it anew: a code mailed to that
  address for this (EmailCodePurpose.firstPassword, its own mail), never
  the session alone, a code asked for anything else, or another address.
- An account that signs in with GitHub alone does it within 10 minutes of
  a GitHub sign-in (the session's method and creation time, where the
  session lives; members tell the register), refused signInAgain before
  any code is used; the address it adds is proved as before.
- Anonymous people still become identified with a code to the address
  they add.

The app mails the code to the person's own address (asking for it where
the device doesn't know it), offers a password right after a GitHub
sign-in (D2's "at their next sign-in") before the person's app, and asks
for a GitHub sign-in anew when the instance refuses an older one, keeping
the code typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:50 +02:00
ae57cbe864
feat(auth)!: codes by mail no longer sign in
ADR 0017, step 3 (D4: nobody signs in with email codes yet, so the
password replaces them outright). `emailCode.verifyCode`,
`emailSignIn.signIn` and the switch token go, with the register's
`verifySignInCode` and `completeEmailSwitch` and their operations
between services. `emailCode.sendCode` stays, now without a session
(signing up and getting back in have none), as what proves an address
for a password's setup, a sign-up, a setup never finished and the
resets; `accountRecovery.sendCode`, which did that for the resets,
goes. The `switchChallengeId` column stays: Serverpod's challenges
still link a completion token, and older servers read it during a
deploy.

The apps: halkyon_app's email sign-in (its bloc, sheet, gateway calls
and the start screen's Continue with email) goes; Learn's profile and
Todo's account sheet show `AccountSecuritySection` (a password set up
by someone anonymous or signing in with GitHub, whose end it names;
the authenticators; changing the password; new recovery codes and
phrase), which reads anew after every change the repository makes.

BREAKING CHANGE: Learn's `minimumAppBuild` is 22: builds up to 21 sign
in with the endpoints that went. Todo was never released and stays at
build 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:41 +02:00
a4819c3d39
feat(app): sign in with a password and TOTP on the device
ADR 0017, step 3: halkyon_app's side, which Learn and Todo take.

- The account's cryptography on the device (`SodiumAccountCrypto`):
  halkyon_keys and SRP's client half, each step on an isolate of its
  own. sodium hands out results as views of native memory its
  finalizers free once the isolate that made them ends, so everything
  crossing back is copied first: a view read freed memory and crashed
  the process.
- `PasswordGateway` on the module's endpoints (steps without a session
  through a client holding none; an authenticator set up with a session
  granted but not yet the device's), and `PasswordRepository` running
  the flows: signing in (M2 checked, the keys opened before anything is
  trusted), signing up, a password for someone signed in, an account
  never finished, the authenticators, recovery codes, changing the
  password, a new recovery phrase, and getting back in.
- The unlocked keys in the system's keyring per instance and person
  (`SecureAccountKeyStore`), gone once another person signs in or
  nobody, and with the instance's removal.
- The screens: the start screen's Sign in and Create an account (and
  when GitHub sign-in ends), the sign-in sheet (codes, recovery codes,
  an account never finished, a lost authenticator), the account's setup
  (the recovery phrase shown once, three random words typed back before
  anything reaches the instance; the authenticator's QR code, drawn
  with pub.dev's qr; the recovery codes), getting back in, and the
  account section (`AccountSecuritySection`).
- `FileSaver` moves from Learn into halkyon_app, for the phrase and the
  codes in Todo too; `AppDependencies` names it and `passwords`.
- halkyon_keys rebuilds account keys from the secret keys a device kept;
  the key proof's message is in halkyon_base_client, checked against
  the register's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-23 14:12:41 +02:00
ce2c00c7ea
chore(release): v0.18.0
All checks were successful
CI / check (push) Successful in 24m6s
CI / deploy (push) Successful in 46s
Build 21.

Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 23:20:51 +02:00
87500b7c6b
refactor(halkyon_app): the account section, About, the language and countries' names, shared
What Learn and Todo each held now comes from halkyon_app:
- AccountTiles: whose account on which instance, switching, signing
  out (asking first for an anonymous account, as it deletes it) and
  deleting it (asking first), the app's own words passed in.
- showAppAbout: the app's name, version, icon and legalese as it gives
  them, the source of what the instance runs (AppDependencies gains
  sourceCodeUrl), the app's own parts, the licence list.
- resolveAppLocale: the first preferred language the app speaks, never
  by country alone, British English otherwise.
- AssetCountryNames, the default of InstanceGate and InstanceScope:
  Unicode CLDR 48.2's territory names in English and German
  (tool/country_names.py, 7.6 KB), Hong Kong, Macao, Myanmar and
  Palestine by their short names as Learn's world map has them, so
  Todo says "Germany (EU)" where it said "DE (EU)".

Learn's texts read as before in every language; its About bloc and
gateway, its country names from the world map and the keys only the
moved code used are gone, as are Todo's copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:08 +02:00
4107cc3087
refactor(profile): handles and profiles move into halkyon_base
People's public profiles are the instance's register's (ADR 0016), so
the module now holds them: the public_profile table (unchanged, so
Learn's migration records versions only; devices get the table empty,
as they hold every module's tables), the profile and profiles
endpoints under `base`, the handle rules, the link checks and the page
at /@handle, whose foot names the app as the host's configuration says
(profilePageNote). Deleting a person deletes their profile in the
module's core.

Hosts read profiles through the module (profileOf, profilesOf,
personWithHandle, fixHandle; saveProfile for their tests), behind an
AccountRegister that is this server's own database for now. Learn's
feedback, discussions and publishing ask it; publishing fixes the
handle just before it writes the release, outside its transaction.
Explore still joins the table in its search, so Learn holds the
register wherever it runs. Learn's app calls the module's endpoints;
a profile not found is ProfileNotFoundException. Todo's database gets
the table too (it was never deployed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:07 +02:00
38770569ea
refactor(halkyon_app): the app says where GitHub is added later
The start screen's sentence after a GitHub account nobody here signs in
with named Learn's Profile screen. The package now says only that no
account signs in with it; the app's frame adds the rest as a sentence
of its own (thenAddGitHub), since the place takes its language's
grammar ("im Profil"). Learn's reads as before, which a test now pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:05 +02:00
1b62c8098c
docs: one repository for every app and shared package (ADR 0015)
The root AGENTS.md holds what applies to every app and package (how
work is done, Serverpod, checks with Melos and the tools, CI); Learn's
own is apps/learn/AGENTS.md, Todo's a note that it is still the
scaffold. The root README introduces the project, Learn's moves to its
folder; LICENSE.md says which licence covers which directory, Todo's
packages unlicensed until decided; one CONTRIBUTING.md. ADR 0015
records the decision; ADR 0014 and 0003 note what moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:04 +02:00
9643412be9
build: one workspace for every app and the packages they share
The root pubspec lists Learn's, Todo's and the shared packages, which
the apps now take by path instead of pinned commits of halkyon_shared:
one resolution, one lockfile. Learn's versions stay as they were; Todo
moves to Serverpod 4.0.4 and Dart 3.13 with the rest (its generated code
unchanged), drops google_fonts, which it never used, and a template
line Dart 3.13 refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:03 +02:00
649499be23
chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its
assets (maps, Anki fixtures, icons), as they are; paths are fixed in
the commits that follow.

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