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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-21 18:17:08 +02:00
commit a3b70175f6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 105 additions and 21 deletions

View file

@ -25,7 +25,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by halkyon_app's `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonDependencies.of(context)`.
- **The device's own settings** (appearance, language, citation style, the PDF sidebar's share of the room as last dragged; `core/device_settings/`) belong to the device, not to a person: kept in `<app support>/device_settings.json`, read in `Dependencies.create()` so the first frame already has them, and provided by `DeviceSettingsScope` around halkyon_app's `SessionGate` (`LearnSessions`, `lib/src/app/`), whose plain app takes its look from it (theme mode and language, `DeviceSettings.look`), as `PersonApp` does; halkyon_app's `InstanceGate` reads the same two before any instance opens (`readLook`). What should follow the person to their other devices goes into their synced `PersonSettings` instead.
- **Every person has an app of their own** (`PersonApp`, `lib/src/app/`), which `SessionGate` builds inside the keyed subtree: a `MaterialApp.router` with its own `GoRouter`, and `PersonScope` around the router's navigator (`MaterialApp.builder`). So every screen and dialog is inside the person's scope and reads their repositories from `PersonScope.of(context)` wherever it is built, and a change of person disposes the router with every screen on it and starts again at home. Until someone is signed in, `SessionGate` shows a plain `MaterialApp`: the spinner, or the failure with a way to try again. **The gates are halkyon_app's** (ADR 0014, shared by Halkyon's apps): `InstanceGate`, `SessionGate`, `PersonScope`, `AppScope`; Learn gives them `learnFrame` (`lib/src/app/learn_frame.dart`: its name and word, kingfisher, theme, languages, launch screen, and the two sentences only it can say, the first start's subtitle and what a switch leaves behind, in its own words), its dependencies, `ServerpodInstanceChecker(app: 'learn')` and its `CountryNames` (`main.dart`).
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), saving files the app made, such as exports (`FileSaver`: `file_selector`'s dialog on the desktop, `file_picker`'s on Android and iOS, where `file_selector` has none), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), cutting Japanese on the device (`JapaneseAnalyser`: MeCab, which needs the IPADIC the app downloads, on an isolate of its own), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one), zipping an export with its files on another isolate (`ExportArchiver`, for the same reason), and the Pomodoro timer's side of the system (`PomodoroPlatform`, `FakePomodoroPlatform` in tests).
- **Platform plugins sit behind small interfaces in `Dependencies`**, with fakes in tests: picking files (`FilePicker`, `file_selector`), saving files the app made, such as exports (`FileSaver`: `file_selector`'s dialog on the desktop, `file_picker`'s on Android and iOS, where `file_selector` has none; halkyon_app's `writePrivately` writes them the own user's alone, on macOS into the chosen file itself, which the sandbox's `files.user-selected.read-write` entitlement allows: keep it, or nothing saves there), playing sounds (`SoundPlayer`, `audioplayers`), playing YouTube videos (`YoutubeVideos`, `youtube_player_iframe` where a web view exists, else the browser), reading PDFs (`PdfEngine`, pdfrx on PDFium, which `flutter test` can't load: widget tests use `FakePdfEngine`, whose PDF bytes say their pages and chapters), cutting Japanese on the device (`JapaneseAnalyser`: MeCab, which needs the IPADIC the app downloads, on an isolate of its own), reading Anki packages on another isolate (`AnkiPackageReader`; a widget test's fake clock can't wait for one), zipping an export with its files on another isolate (`ExportArchiver`, for the same reason), and the Pomodoro timer's side of the system (`PomodoroPlatform`, `FakePomodoroPlatform` in tests).
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
- **State: `flutter_bloc`, uniformly.** Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one global `BlocObserver`. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point.
- **Value equality comes from `equatable`**, mixed in (`with Equatable`), for models and states alike; `leancode_lint`'s `missing_equatable_props` catches a field left out of `props`.

View file

@ -592,7 +592,70 @@ keychain on 21 Oct; the 30-day grace for GitHub-only accounts was proposed and k
- **Left as they are:** the per-address cap on codes by mail (the re-review's 9);
`sharingType = .none` doesn't hold against ScreenCaptureKit on macOS 15 and later.
### The rules as they stand (21 Oct 2026, after reading the fixes)
### The short reading (21 Oct 2026)
A short reading of the 14 commits above, by a reviewer who hadn't seen them made.
Nothing critical; one high, three mediums. The developer decided the four questions on
21 Oct: resets are held back 14 days at most, macOS writes in place, a member deletes
nobody while the register is out of reach, and the end-to-end trial follows without
another reading.
- **H1 (high):** `open(2)` takes its mode among variable arguments, which Apple's Arm
passes on the stack; bound with fixed ones, a phrase or codes saved on an Apple
silicon Mac took their mode from whatever the stack held. Bound as variadic now, and
every file written is made 0600 through its descriptor (`fchmod`), checked.
- **M1 (medium):** macOS's sandbox lets an app reach the file chosen and nothing beside
it, so the new file beside it was refused and nothing saved; Learn's entitlement was
read-only, so its exports never saved there either. On macOS the chosen file is
written in place now: opened without following a link, waiting on a pipe or
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
chosen files. Whoever held the file open reads what is saved; Linux keeps the new file
renamed over it, and flushes the folder. Untried on a Mac.
- **M2 (medium):** a member took the register's notice of a reset by its own clock, so
a notice that took longer on its way than the reset's answer ended the very session
the reset had signed in. A member remembers that session, by the account and the
register's moment, and keeps it when the notice comes (in memory for a day; a member
restarted meanwhile ends it as before).
- **M3 (medium):** someone knowing only the address can fill its ceiling every hour, so
hold-outs kept any reset, the owner's own recovery too, from ever taking effect. A
reset now takes effect 14 days after it was asked at the latest, held out or not
(`resetWaitsAtMost`): a stranger delays a recovery two weeks at most, and whoever
means to take the account over with the password and the mailbox (or the phrase) must
hold its owner out for all of them, every wait mailing the owner. It lapses a week
after it took effect, as before, so within 21 days; the 30-day bound stays as the
outer one.
- **L1 (low):** a refusal at the ceiling pushed a waiting reset inside the request, and a
write's commit waits for the log, so its time told that the address had an account
with a reset waiting. The push runs after the refusal now, on a session of its own.
- **L2 (low):** with the register out of reach, a member deleted a person still
anonymous there on the session alone, though the register might have linked them (a
becoming identified that failed). A member deletes nobody while it can't ask the
register; the app gives an anonymous account up all the same (signing out, or a
switch), leaving it to the pruning of anonymous accounts unused for 90 days.
- **L3 (low):** the fields the phrase is typed back into offered Copy, Cut and Share.
Paste and Select all alone now, and their copy and cut keys do nothing.
- **L4 (low):** on iOS the cover rebuilt what it covered, so a phrase field lost its
focus at each switch of apps. The cover sits in a stack always there.
- **L5 (low):** keys an earlier build kept with another keychain accessibility stayed
after signing out (an Apple keychain's read finds only its own accessibility's). They
are found with every accessibility a build used and deleted by name; a locked keyring
stops nothing, its error no longer unhandled.
- **L6 and nits:** the test of another user's file is skipped as root; a GitHub-only
account deleted whose step afterwards failed counts as deleted; the app says the real
day such an account goes (30 days after the end); `signInWays` has its comment back;
the file saver wipes its buffer whatever happens, calls again when interrupted, names
its new file apart from the one chosen (a name as long as the folder takes), and reads
types and owners on Linux with `statx` (`__lxstat`'s version was wrong on riscv64);
Windows chooses its capture affinity by its real version (`RtlGetVersion`: before 10
2004 `WDA_EXCLUDEFROMCAPTURE` acts as `WDA_MONITOR` without failing); Delete says
when the instance is out of reach rather than showing a spinner, then nothing;
`sharesStorage` is required; a forgotten `PasswordUnlock` is spent.
- **Left as they are:** the per-address cap on codes by mail (the re-review's 9); a
hot restart in a debug build leaves the native capture count held; nothing has run on
macOS or Windows.
### The rules as they stand (21 Oct 2026, after the short reading)
Who changes what of an account, with which proofs. "Password" is SRP's proof bound to
the instance's base URL the device addressed, and to what it approves (the purpose and
@ -613,8 +676,8 @@ wrong in a row, the owner mailed).
| New recovery codes | — | password + code |
| New recovery phrase | — | password + code |
| New password | phrase's proof + code (keys kept) | password + code |
| Authenticator reset | password + (mailed code or phrase's proof); takes effect once 72 h outside hold-outs of the owner passed, owner mailed, called off by a sign-in with a code; lapses a week after taking effect unless carried out, and 30 days after it was asked | password + code calls it off |
| Account reset (new keys) | mailed code (`accountReset`) asks, a new one carries it out once 72 h outside hold-outs passed, owner mailed, called off by any sign-in with the password; lapses a week after taking effect, and 30 days after it was asked | password + code calls it off |
| Authenticator reset | password + (mailed code or phrase's proof); takes effect once 72 h outside hold-outs of the owner passed, 14 days after it was asked at the latest, owner mailed, called off by a sign-in with a code; lapses a week after taking effect unless carried out | password + code calls it off |
| Account reset (new keys) | mailed code (`accountReset`) asks, a new one carries it out once 72 h outside hold-outs passed, 14 days after it was asked at the latest, owner mailed, called off by any sign-in with the password; lapses a week after taking effect | password + code calls it off |
| Delete the account | — | password + code; where no second factor works, password + mailed code (`deletion`) |
Every reset carried out, and every new password (a setup's replaced too), ends the
@ -622,9 +685,15 @@ account's sessions in every service (a change keeps the session asking). A sessi
for ten wrong proofs leaves its owner a warning; a session made since dismisses it, and a
new password clears it. 30 days after GitHub's end, accounts that sign in with GitHub
alone go, on the register. A member ends the sessions an account had on it before the
register's moment by its own clock. On the device nothing secret is copied, every
secret shown or typed back is kept out of captures, and a saved file is the own user's
from the start.
register's moment by its own clock, but the one a reset signed in there, and deletes no
account while the register is out of reach. On the device nothing secret is copied
(the phrase typed back is pasted, never copied out), and a saved file is the own user's
alone (on macOS, the chosen file written in place). Every secret shown or typed back is
kept out of screenshots, screen recordings and recents on Android; out of captures on
Windows 10 2004 and later, before that shown black in them; on macOS out of
screenshots, but not out of ScreenCaptureKit's captures on macOS 15 and later; on iOS
covered in the app switcher's picture, screenshots and recordings not held back; on
Linux not at all.
## Consequences

View file

@ -67,7 +67,9 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
the system's keyring, per instance and person, gone once another person
signs in or nobody; on iOS and macOS `unlocked_this_device`, so readable
only while the device is unlocked, never in a backup restored elsewhere nor
in iCloud Keychain, each write deleting the entry before it). Signing in (SRP's M2 checked and the keys opened before
in iCloud Keychain, each write deleting the entry before it, and forgetting
finding too the entries earlier builds kept with another accessibility, a
keyring that can't be read now stopping nothing). Signing in (SRP's M2 checked and the keys opened before
anything is trusted), signing up (the recovery phrase shown once and three of
its words typed back before anything reaches the instance), a setup never
finished, the authenticators (QR codes drawn with pub.dev's `qr`), recovery
@ -87,13 +89,17 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
through Flutter, so clipboard histories and cloud clipboards would keep
it): the recovery phrase and codes are saved or written down, an
authenticator's key scanned, opened or typed; all of them show in a
`SecretScreen` (the phrase typed back too), kept out of screenshots,
`SecretScreen` (the phrase typed back too, its fields offering Paste and
Select all alone, their copy keys doing nothing), kept out of screenshots,
screen recordings and recents where the app's window answers
`app.halkyon/screen_guard`: Android's `FLAG_SECURE` (as Learn's and Todo's
`ScreenGuard.kt` in their `MainActivity`), macOS's `sharingType = .none`
(`MainFlutterWindow.swift`) and Windows' `WDA_EXCLUDEFROMCAPTURE`
(`flutter_window.cpp`); covered on iOS while the app isn't in front;
nothing on Linux; keyboards learn nothing typed into a password, a code or
(`MainFlutterWindow.swift`; screenshots, but not ScreenCaptureKit's
captures on macOS 15 and later) and Windows' `WDA_EXCLUDEFROMCAPTURE`
from Windows 10 2004 on, `WDA_MONITOR` (black in captures) before, by the
version `RtlGetVersion` tells (`flutter_window.cpp`); neither run yet;
covered on iOS in the app switcher's picture while the app isn't in front,
screenshots there not held back; nothing on Linux; keyboards learn nothing typed into a password, a code or
the phrase; no state's text (which a debug build's bloc observer logs) holds
a secret, a code or the address; only an `otpauth:` URI is shown as a QR
code or opened; and anything a step didn't expect (bytes of the wrong

View file

@ -104,8 +104,12 @@ the same ID. Sessions stay each service's own: every app signs in itself.
under the account's ID the first time). Linking a way in makes the person identified on
the register and then on the member; a switch token signs in as the account the
address is already. Every refusal is the register's, as on the register itself.
- **Deleting:** an identified person's deletion on a member goes to the register first
(refused while it can't be reached, deleting nothing), which deletes the account and
- **Deleting:** a deletion on a member asks the register first whose account it is
(`knownAccounts`), and is refused while it can't be reached, deleting nothing, a
person still anonymous on the member too: the register may have linked them while
their becoming identified there failed (the app then gives an anonymous account up
all the same, leaving it to the pruning). An account of the register's goes there,
which deletes the account and
keeps a notice for each member in the same transaction (`account_deletion_notice`),
told at once and again hourly (`deliverDeletionNotices`, tried least first) until each
confirms; notices to a member no longer named are dropped. An anonymous person goes
@ -293,11 +297,14 @@ Built on the three parts above, on the register (members forward every step thro
password, the keys staying. A lost password without it: a mailed code asks, 72 hours
pass (a mail at the start, a sign-in calls it off), a new code carries it out: new
password, new keys, no authenticators. A reset takes effect once the owner had 72 hours
outside hold-outs (codes waiting, the address's ceiling of proofs filled): each
outside hold-outs (codes waiting, the address's ceiling of proofs filled), and 14
days after it was asked at the latest, held out or not (`resetWaitsAtMost`): each
hold-out that begins before it takes effect moves it on by the time it holds the owner
out and no hold-out counted before did (`pushOutResets`, `account_resets.dart`, the
row's `heldOutUntil`); a reset that took effect and wasn't carried out lapses a week
later (`resetLapsesAfter`), and every reset not carried out 30 days after it was asked
out and no hold-out counted before did, never past that (`pushOutResets`,
`account_resets.dart`, the row's `heldOutUntil`; a refusal at the address's ceiling
pushes after it has answered, on a session of its own, so its time tells nothing); a
reset that took effect and wasn't carried out lapses a week later
(`resetLapsesAfter`), and none outlives 30 days after it was asked
(`resetLivesAtMost`), both pruned by `forgetLapsedResets`; `SignInWays` tells
signed-in devices when each waiting reset takes effect, and `password.callOffResets`
calls them off for the owner's proof.
@ -325,7 +332,9 @@ Built on the three parts above, on the register (members forward every step thro
taken by its own clock (the notice carries the register's, `sentAt`, so the clocks'
offset drops out); a member signing someone in after a reset ends every session of the
account it holds made before the register's moment or before now, whichever is later,
then makes the new one (`issueSessionAfter`), waiting for nothing. The owner's warning of a session ended for its proofs is
then makes the new one (`issueSessionAfter`), waiting for nothing, and keeps it when
the register's notice of that moment comes, however late (remembered in memory for a
day). The owner's warning of a session ended for its proofs is
dismissed only by a session made since (`signedInBefore`). A refusal for a reset not due says when it takes effect
(`takesEffectAt`).
- **Older ways:** an account that signs in with a password (it had a confirmed
@ -336,8 +345,8 @@ Built on the three parts above, on the register (members forward every step thro
then) and, 30 days later (`gitHubAccountsKeptAfterEnd`, so a date set wrong can be put
right first), `forgetGitHubAccounts` deletes every GitHub ID on the register, first
deleting the accounts that sign in with GitHub alone (no address, never a password),
each on its own (one failing is tried again the next day), the members told once
after all of them. `password.set` adds an
each on its own (one failing is tried again the next day; one deleted whose host step
after failed counts as deleted), the members told once after all of them. `password.set` adds an
address without GitHub only for someone anonymous where the session lives
(`anonymousHere`, which a member tells the register).