# Halkyon Learn What holds for every app and package of this repository (how work is done here, Serverpod and its MCP tools, the checks, Melos, CI) is in the [root AGENTS.md](../../AGENTS.md); this file holds what is Learn's own. Paths start at the repository's root (`apps/learn/halkyon_learn_server/…`, `deploy/…`) or, where they name a file inside one of Learn's packages, at that package (`lib/src/…`, `test/support/…`). A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS schedule. Content is rich text (`appflowy_editor` documents) everywhere. Targets: Linux, Windows, macOS, Android, iOS. There is no web build. - **Packages** (`apps/learn/`): `halkyon_learn_server` (endpoints, models, migrations), `halkyon_learn_client` (generated, never edited), `halkyon_learn_flutter` (the app, ID `app.halkyon.learn`), `halkyon_learn_curator` (the dictionaries' converter, below), and `tool/` with the scripts that write the app's assets (maps, icons, Anki fixtures; run them from `apps/learn`). What every Halkyon server has comes from the Serverpod module `halkyon_base` (ADR 0014, decided 2026-10-19; `packages/halkyon_base_server` and `packages/halkyon_base_client`, which Learn's packages take by path): signing in, the account's ways in and its deletion, the pruning, and the instance's basics. The server sets it up for Learn before it starts (`HalkyonBase.configure(pod, learnBase(accounts: learnAccounts()))` in `server.dart`; `lib/src/auth/learn_base.dart`: Learn holds the instance's register of accounts (ADR 0016), `learnAccounts` reading the members from `INSTANCE_MEMBERS` and refusing to start with `ACCOUNT_REGISTER_URL`; the app it serves, `learn`, which its instances name in their basics and the app checks (never change it), the app's name in sign-in mails, this project's source, `minimumAppBuild`, Learn's own deletion as a hook), its endpoints are `client.modules.base` in the app, its tables (`email_identity`, `email_code_request`) are the module's, and tests that delete people or ask `instance.info()` set it up as the server does (`useLearnBase(sessionBuilder)`). What every Halkyon app's shell has comes from `halkyon_app` on the module's client (ADR 0014, `packages/halkyon_app`; with `halkyon_core`'s `SerialQueue` and `CoalescingRunner`): the instances and the first start, signing in and switching person, the gates (`InstanceGate`, `SessionGate`, `PersonScope`, `AppScope`), the sync service and where it stands, with texts of its own (`HalkyonAppLocalizations`, in `appLocalizationsDelegates`) that name the app from `learnFrame`. Change what they hold there, not here. - **Instances**: anyone may run one. The app takes the server URL from `--dart-define=SERVER_URL=…`, so never hard-code a host. ## Working on Learn - **`serverpod start`** runs in `apps/learn/halkyon_learn_server`, and the `serverpod` MCP tools bridge to it: `.mcp.json` (git-ignored) names a server `serverpod` running `serverpod` with `--no-analytics mcp-server --server-dir apps/learn/halkyon_learn_server`; the VS Code task and launch configurations (`.vscode/`) start and attach to it. - **The app is launched from `apps/learn/halkyon_learn_flutter/lib/driver.dart`**, which starts the Flutter driver extension with text entry emulation turned off so the app stays usable by hand. To let the driver type, set `enableTextEntryEmulation: true` there and `hot_restart` the app. - **After a migration that adds or changes synced tables, restart `serverpod start`** once it has applied it. The sync engine (checked with `serverpod_offline_sync` v0.0.8) reconciles its registry of synced tables when a server first uses the database, and if that changed the registry, every later session of that server rebuilds the projections of every space, one transaction each, until the server stops ([serverpod_offline_sync#150](https://github.com/marcelomendoncasoares/serverpod_offline_sync/issues/150), open). With the many people the tests leave in the development database, syncs then take seconds and the sync tests time out. The restarted server finds the registry as it left it. Deploys are affected too (found 2026-10-13: after v0.13.0's deploy every request took a minute or two and syncs failed, as the new server, not the maintenance run, reconciled the registry), so `deploy/redeploy.sh` starts the server once more after a deploy that brought a migration. Our fork fixes #150 (`halkyon/fast-merges`, 2026-10-17): drop this note, and that restart, once a deploy runs it. ## App architecture - **Feature-first.** App: `lib/src/core/` holds infrastructure shared by features, `lib/src/feature//` everything a user would name, and `lib/src/app/` puts the features together (the person's app and its router, the only code that knows them all). Server: `lib/src//` holds that feature's `.spy.yaml` models and endpoints. Code moves into `core/` only once a second feature needs it. Why: a feature is added, changed or removed in one place. - **Four layers per app feature**, dependencies pointing strictly downward: `widget/` (UI; reads bloc state, sends events) → `bloc/` (UI state; orchestrates repositories) → `data/` (repositories and gateways) → `model/` (immutable domain types; pure Dart, no Flutter, no I/O). A widget never calls a gateway; a model never imports a bloc. - **Generated protocol classes are not domain models.** They carry the envelope (`payload` as bytes); `model/` holds the decoded content. Mapping between them happens in `data/`. - **Import `halkyon_learn_client` with a prefix (`as protocol`) or a `show` list.** The generator emits a `Repository` for every table, and its names clash with the app's own repositories. - **Gateway / repository.** A gateway is a thin boundary to *one* backend (e.g. the Serverpod client's endpoints) behind an `abstract interface class`. A repository is what features use: it composes gateways and holds the logic. Blocs depend on repositories, never on gateways or the raw client. Why: gateways are the seams tests fake, and adding the offline layer later becomes a new local gateway, not a rewrite of every feature. - **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built for the instance in use (`Dependencies.create()`) and exposed through halkyon_app's `AppScope` InheritedWidget, whose parts it provides (`AppDependencies`); widgets reach it through `Dependencies.of(context)`. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic. - **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another'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 `/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 launch screen, then the start screen. **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 sentence only it can say, the first start's subtitle, 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; 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`. - **Rich text is `RichContent`** (`lib/src/core/content/`): the editor's document JSON plus a `formatVersion`, pure Dart, so models and the server can use it. `lib/src/core/editor/` converts it to and from the editor's `Document` (giving tables the decimal sizes the editor casts to: JSON from imports and archives may hold whole numbers, or none; our fork of the editor fits a read-only table's rows to their cells, which upstream's leaves at their stored height) and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`). Copying and pasting keep every format between the app's editors (`EditorClipboard`, decided 2026-10-11): the system's clipboard holds plain text only, so the blocks copied last stay in memory beside it and a paste takes them while it still holds their text, plain text otherwise; the editor's own copy, cut and paste are replaced (Ctrl+C, X, V), and on phones a bar over selected text (the editor's `MobileFloatingToolbar`, over text only) copies, cuts, pastes and selects all. Links come from the format bar's link button and Ctrl+K (`link.dart`, in place of the editor's own link menu, English only); an address without its scheme leads to the web. Text above other text (ruby, `ruby.dart`, decided 2026-10-12) is named in the content's language, furigana over Japanese, pinyin over Chinese, else text above (`ContentLanguages`, in `core/widget/`, which every screen of a studyset sets through its language scope); read-only it shows only under the pointer or after a long press, its room kept so lines don't move, and while editing over the pointer; with the caret inside such text, the format bar's button edits all of it. - **Blocks** beyond the editor's own (callout, spoiler, formula, image, sound, YouTube video, study button) keep their type and attributes as named in `core/content/content_blocks.dart`, which `RichContent` reads too (their text counts in `plainText`, and a video or study button isn't blank). An `EditorBlocks` lists how each type is built, which can be inserted (the format bar's Insert sheet and the desktop's `/` menu show the same list) and its typing rules; the core's are `coreBlocks`, and a feature's (the study button, `feature/study/`) reach the editors through `EditorBlocksScope`, which `PersonApp` fills, since the core can't know features. One builder serves editor and view: a block asks `editorState.editable`, and read-only shows what it is for (a spoiler hidden until revealed, a formula set, a video's play). A block taken whole mixes in `AtomicBlock` (selection, the remove button); unknown types show "needs a newer version" and stay as they are. New block types keep `formatVersion` 1: raising it would hide whole studysets and paths in older apps. - **Time comes from `clock.now()`** (`package:clock`), never `DateTime.now()`, in server and app. Why: scheduling, streaks and expiry are deterministic under test (`withClock`, `fakeAsync`), and because Serverpod itself reads `clock.now()` (e.g. session expiry), `withClock` controls its behaviour too. - **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries. - **Build configuration comes from `--dart-define`.** The client holds no secrets, ever. - **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved. - **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*). - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById`: a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **The look is one theme, every Halkyon app's**: the theme, its fonts, the window sizes and the shared widgets come from `halkyon_ui` (`packages/halkyon_ui`, decided 2026-10-18). Change them there, not here. `appTheme(brightness, extensions:)` with hand-written light and dark colour schemes around the brand's cobalt and copper, which Learn calls as `learnTheme` (`core/theme/`) with its own colours beside; `Theme.of(context).extras` (`ThemeExtras`) for the states Material has no role for, and `Theme.of(context).learnColors` (`LearnColors`) for Learn's own (the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen and Settings (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. halkyon_ui's `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface, and Learn's `learn_theme_test.dart` a starred star to 3:1 on the cards. **Fonts are bundled, never fetched** (halkyon_ui's `app_fonts.dart`; the CJK fonts are the app's own, `assets/fonts/ATTRIBUTION.md`, their licence registered by `core/theme/cjk_fonts.dart`, under the families halkyon_ui's fallback names): Noto Sans, from halkyon_ui, is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets come from halkyon_ui (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` and `BrandWordmark` ("Halkyon" over an app's word), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below), with texts of their own (`HalkyonUiLocalizations`); Learn's own are in `core/widget/` (`ConflictVersions`, the versions of a conflict each editor shows, with Keep, its save status beside it `SaveStatusLabel` in `core/editor/`; `ContentCountsLine`; `AppLaunchScreen`, what shows while the app opens: the kingfisher, "Halkyon" over "Learn", whose wordmark the first start shows too). - **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`). - **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first tab with something in it; a tab with none of its own while the studyset's topics hold some says that it is in them, each topic holding some with how much, everything inside it counted, opening it, Browse all for flashcards, and how to add some here after all, decided 2026-10-14 after people testing the app looked for a grouping studyset's material in its empty tabs; the tabs' counts stay its own), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`; how many steps of its learning paths are done and the PDFs not read to their end, begun first, each opening the reader, decided 2026-10-17: `MaterialProgressCubit`; all of it across its topics) and Study plan (the paths finished; Next up: studying for the exam while one is ahead and its pace leaves cards for the day, then the cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; at the top the exam the person prepares for, below; `PathListState.stepsDone`). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`). - **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home. - **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana, accents, tone marks and umlauts left out, so strasse finds Straße and xue xi xué xí; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, by bare letters, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache). - **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked. - **Every text field has a limit** (decided 2026-10-11): `TextLimits` (halkyon_ui) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, an email address 254, a message or a web address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names. - **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (halkyon_app's, shared by every Halkyon app) picks the language, never by country alone, and every `MaterialApp` takes `appLocalizationsDelegates` (halkyon_app's and halkyon_ui's texts among them; halkyon_app's plain apps take it from `learnFrame`), not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files. ## Testing - **By layer**: models with plain unit tests; repositories against fake gateways; blocs by asserting the emitted state sequence; widgets pumped in isolation, presentation widgets fed with ready-made data. - **Server endpoints are tested for real** against the embedded Postgres (`withServerpod`, `dart test` in the server package), including the denial tests. - **Test dependencies come from a test factory**, `testDependencies(...)` in `test/support/test_dependencies.dart`, filling in fakes by default (a factory in `lib/` couldn't reach the fakes in `test/`), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code. - Shared fakes live in `test/support/`, those of halkyon_app's boundaries in its `package:halkyon_app/testing.dart` (`FakeAuthGateway`, `FakeInstanceChecker`, `MemorySignInAddresses`, `MemoryInstanceStore`, `MemoryPendingChangesStore`), which `testDependencies` fills in; fakes pre-stub their streams so an incidental subscription doesn't fail the test. - **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests. - **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository's `all()` instead. For the same reason a repository never keeps a completed future to chain later work on (a `Future.value()` made in `setUp` runs what waits for it in the real zone): a queue of writes starts empty and empties again when idle (`SerialQueue`, halkyon_core), and a cache keeps values, not futures (`GeoMapRepository`). Assets are read for real, which the fake clock doesn't advance: a widget test showing a map gets the maps read beforehand (`loadedMaps(tester)` into `testDependencies(maps: ...)`), and one showing how a character is written its strokes (`loadedStrokes(tester, characters)` into `testDependencies(strokes: ...)`). Nor is a subscription that has finished cancelled with `await` there (`await subscription.cancel()` on a stream done): its cancel future never completes under the fake clock either, which hung everything after it (found 2026-10-26 with the flashcards an assistant proposes); cancel it unawaited (`CardProposalsCubit`), or forget it in `onDone` and `onError`, so only one still running is awaited (`ConversationCubit`). - **Text inside the editor's views** (`RichContentView`, `RichContentEditor`) is rich text: find it with `findRichText: true`. - **Layout-sensitive widget tests** set `tester.view.physicalSize` explicitly, so a responsive branch isn't missed for the wrong reason. - **A menu item acts in the frame after its menu closes** (`MenuAnchor`): pump twice after tapping one before expecting what it does. - **Server tests of what writes synced rows** run on the sync engine's database, as the server does (the generated test tools build the pod without it): pass the interceptor to `withServerpod`, call `initializeOfflineSync` in `setUp`, and write as a person through `transactionForUser`. Its writes take transactions of their own, so these tests turn rollback off and delete what they made (`test/integration/asset/asset_test.dart`). - **Sync tests run against a running server** (ADR 0001, rule 9: the client's and the server's sync engines can't share one process): `apps/learn/halkyon_learn_flutter/test/sync/`, tagged `requires-server`, skipped by `tool/check.dart`; run them with `flutter test -t requires-server` while `serverpod start` runs (from a worktree against another checkout's server, `SYNC_TEST_SERVER_DIR=` for the SQL checks). A test device syncs through its `SyncService`, as the app does, so what a sync merges reaches its repositories, which keep what they read until then. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a row that must not exist) is checked with SQL against the development database, through the embedded PostgreSQL's Unix socket where there is one. An honest device only syncs the spaces the server announces, so a forged write never leaves it; the server's own check against modified clients (`_assertCanMergeInboundSpace` in the sync engine) has no test yet. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (no `testWidgets`), which would replace real HTTP with a mock. - **Widget tests of screens start the whole app**: `pumpApp(tester, testDependencies(...), location: …)` (`test/support/pump_app.dart`) pumps the app as `InstanceGate` shows it on an instance (`learnFrame`, `AppScope`, `LearnSessions` with halkyon_app's `SessionGate`) with the real `PersonApp`, opened at the screen's path, and settles once `PersonScope` has opened the person's data. From there a test moves as a person would: `openTab` (the tab's label alone may also be a screen's title), taps, `pageBack`. Data goes into the fake gateways before pumping; they work in memory (`FakeStudysetGateway`, with `failReads` for the error path), and a repository of the test's own on the same fake gateway writes what the app then reads. `pumpAndSettle` never ends while a spinner shows (`AppLoadingIndicator` animates without end): pump frames instead where one is expected. A widget pumped on its own that reads `AppLocalizations` or is one of halkyon_ui's or halkyon_app's needs `appLocalizationsDelegates` in its `MaterialApp`, which brings their texts too. ## CI and deploys - **CI** checks Learn with every other package (root AGENTS.md, *CI and deploys*): its server's, curator's and app's tests through Melos, its server's generated code through `generate`. - **A green `main` deploys learn.halkyon.eu** through `deploy/redeploy.sh`, the only command the runner's key may run as `deploy` (pinned in `authorized_keys`, from that server itself only). It deploys the commit CI checked while it is still the tip, as a fast-forward, only if every new commit is signed by a key in `deploy/allowed-signers` (the deployed copy decides), after dumping the database (the last 3 dumps stay in `~deploy/backups`: the way back from a deploy, not backups), applies the migrations in a run of their own before switching (`--role=maintenance --apply-migrations`: a failing migration stops the deploy while the old server keeps serving, so migrations must keep the old server working until the switch), and fails unless the new server answers. A failed deploy leaves the checkout at the deployed commit; run the workflow again to retry. By hand: `ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh ` (the server's checkout keeps its folder `halkyon-learn`, so the command its key is pinned to stays). Never `docker compose down -v` there. - **From the single repository** (ADR 0015): learn.halkyon.eu deploys this repository's `main`, whichever app or shared package a push changed. Its images are built from the repository's root with only what they need (Learn's server and the module it builds on, with the module's client, which the module's tests depend on; the curator), each Dockerfile narrowing the workspace to those packages (`tool/workspace_only.sh`). The first deploy after the move starts the server once more than it needs to, as the deployed copy of `redeploy.sh` looks for migrations at their old path. - **Sidecars** (`deploy/sidecars/README.md`, decided 2026-10-14): services an instance may run beside itself, each a compose file of its own beside `deploy/compose.yaml` (`compose.jellyfin.yaml`, `compose.languagetool.yaml`; later Fedify), switched on in `deploy/.env` (`COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml`), so `redeploy.sh` deploys them with the instance. The instance's compose has two networks: `edge` (`halkyon-learn-edge`, 172.30.80.0/24 and the unique local fd4a:6c6b:7e80::/64, so Docker hands Caddy IPv6 visitors' own addresses), Caddy's at fixed addresses (172.30.80.2, fd4a:6c6b:7e80::2; the server's `TRUSTED_PROXIES`), shared with the server, and the internal default one, where the server reaches Postgres; a sidecar joins `edge` alone and publishes no port (the CI runner may reach `127.0.0.1`, never Docker's 172.16.0.0/12 or fd00::/8). A sidecar reached from outside mounts its site from `deploy/sidecars/` into Caddy's `/etc/caddy/sidecars/`, which the Caddyfile imports (none without), and `redeploy.sh` recreates Caddy when a site changes. Each runs as a user ID of its own (Jellyfin 61096, LanguageTool 61081), never one a person, another container or the runner uses; its files on the server are git-ignored (`deploy/jellyfin/`, `deploy/languagetool/`). The Jellyfin sidecar sets the server's `JELLYFIN_URL`, so the instance suggests it; the LanguageTool sidecar (ADR 0024) sets `LANGUAGETOOL_URL` and has no site, as LanguageTool has no sign-in: only the server reaches it, and logs nothing (`logging: none`). - **Backups** (`deploy/backups.md`): every hour, `deploy/backup.sh` (a systemd timer, as `deploy`) dumps the database, which holds people's files too, then bundles each published studyset's repository (`git bundle`, from the server's `repositories` volume), and restic stores both in a Hetzner Storage Box (24 hourly, 7 daily, 4 weekly, 3 monthly); the box's own snapshots protect it from a taken-over server. A restore takes the instance back up to an hour, and people who started since lose what their device held: their app no longer finds its session and asks how to start, and an account made since is gone with it. So a change that makes devices lose their session, or keeps deleted people's data longer, changes what a restore costs; say so in `deploy/backups.md`. - **Releases** are the project's, not an instance's: the release, the Snap Store listing and the F-Droid listing point to the project's page on Halkyon's website, halkyon.app/learn (its own repository, `project-halkyon/website`; learn.halkyon.app redirects there); the builds name learn.halkyon.eu, the project's instance (`SERVER_URL`), which a new install offers first (see *Instances*). They come from a developer's laptop, never from CI: `dart run tool/release.dart learn` on `main` as pushed and green in CI (the apps' shared release tool, ADR 0015, configured in `apps/learn/release.dart`; `--dry-run` shows the version, the commits that count, every step and the notes without changing anything). Build 21 (`v0.18.0`) was the last from `tool/release.sh`, the Bash script it replaced, removed since. Versions follow semantic versioning, worked out from the commits touching Learn since its last release (`apps/learn/`, the shared packages it uses, the root pubspec and lock, its release files, and its packages' folders at the root from before the move; `feat` raises the minor number, `fix`/`perf` the patch number, breaking ones the major from 1.0 on; other types release nothing), so **commit messages keep their type prefix**. It writes the version and a raised build number into the app's pubspec in a signed release commit (`chore(release): learn-vX.Y.Z`) with a signed tag (`learn-vX.Y.Z`, by a key in `deploy/allowed-signers`; Learn's earlier `vX.Y.Z` tags count as its history), builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; pushes only once both builds passed their checks; and publishes on code.halkyon.app through Forgejo's API and in the F-Droid repository. The snap goes to the Snap Store's edge channel last (`storeChannel` in `apps/learn/release.dart`; the release refuses to start unless `snapcraft whoami` shows a login). A run that stopped after its release commit and tag carries on with them. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the release tool releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-feature NonFreeNet declared with its reasons and F-Droid's own icon; not TetheredNet, as the app chooses its instance); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to the halkyon.app server (`ssh ramiel-fdroid`) as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last. ## Rules the code relies on - **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too. - **Identity**: `AuthUser` is the person; identity providers (SimpleLogin, an email address with a password) attach to it, and every account has one (no anonymous accounts since ADR 0020, D25, and no GitHub since D36: an ID left from before signs nobody in and goes with its account). Content references `AuthUser`, never a provider account. A person may sign in with SimpleLogin and with an address and password, one of each (`account.signInWays`, which also says whether they have a password and a confirmed authenticator); the Profile tab lists their ways in and holds halkyon_app's `SimpleLoginSection` (adding SimpleLogin, ADR 0020's D21) and `AccountSecuritySection` (setting up a password, the authenticators, changing it). - **A password and an authenticator** (ADR 0017, built 2026-10-20 for build 22; halkyon_base's and halkyon_app's READMEs): with SimpleLogin, the ways an identified person signs in. The address, a password that never leaves the device (SRP-6a, `passwordSignIn.begin` and `complete`, the verifier and the account's wrapped keys on the register) and a code from an authenticator app (TOTP, mandatory) or a recovery code. An address nobody has is answered as an account's would be (decoys from `signInDecoyKey`), and every wrong credential is refused alike (`wrongCredentials`). Lost authenticator: the password and a mailed code (or the recovery phrase) turn it off 72 hours later, a mail at the start, a sign-in with a code calling it off; lost password: the recovery phrase's signature and a code set a new one, keys kept, or without the phrase a mailed code resets the whole account (new keys) after the same 72 hours. A reset takes effect only once the owner had 72 hours outside hold-outs (codes waiting, the address's ceiling filled: each moves it on by the time it holds the owner out); one that took effect and wasn't carried out lapses a week later, and every reset 30 days after it was asked; the Profile tab's account section shows waiting resets, called off with the password and a code (ADR 0017's full reading). An account with a password and a confirmed authenticator no longer signs in by a mailed code (`usePassword`). GitHub, which ADR 0017's D2 ended on `GITHUB_SIGN_IN_ENDS`, went sooner (ADR 0020, D36, decided 2026-10-25): no endpoint signs anyone in with it, and the IDs left in Serverpod's `serverpod_auth_idp_github_account` are the operator's to delete (ADR 0020, D36, gives the SQL). Password sign-in is on only where the passwords hold `totpKey`, `recoveryCodeKey` and `signInDecoyKey` (`TOTP_KEY`, `RECOVERY_CODE_KEY`, `SIGN_IN_DECOY_KEY` in `deploy/.env`) and email sign-in is set up (`passwordSignIn` in `instance.basics()`). On the device (halkyon_app): signing up shows the recovery phrase once and checks three of its words before anything reaches the instance; signing in checks SRP's M2 and opens the keys before the session is put in place; an authenticator still to set up is set up with the session held back; the unlocked keys stay in the system's keyring per instance and person, gone once another person signs in or nobody. A session alone never gets a code checked (ADR 0017's third reading, as Proton has it): every signed-in change of how the account is secured (adding an authenticator once one is confirmed, removing a confirmed one, new recovery codes, a new phrase, a new password, deleting) takes the password and a code (`OwnerProof`), each sheet asking for both; a session's wrong proofs count for it alone and the tenth in a row ends it, never making the account's codes wait. Nor does a session alone set a first password or confirm a first authenticator (the fourth reading): an account with an address proves it by a code mailed there (`firstPassword`), one without has nothing to prove it with and sets none (the Profile offers it none), and the first authenticator takes the password. A change of password ends the account's other sessions and every reset all of them, the authenticators' included as the sign-in carries it out, on every service of the instance (`sessions_ended_notice`). ADR 0017's "The rules as they stand" lists who changes what with which proofs. - **One account across the instance** (ADR 0016, decided 2026-10-19; halkyon_base's README): Learn's server holds the instance's register, so its tables keep every service's ways in, handles and profiles, and an account's ID is the `AuthUser` ID in every service. The instance's other services (Todo) are its members: they ask it over its web server (`/halkyon-base/register/…`, `addInstanceRoutes` in `server.dart`, never forwarded by Caddy) with the secret the services share (`instanceSecret`, `INSTANCE_SECRET` in `deploy/.env`), and a member's person is an account here under the same ID. Deleting an account (here, or a member passing it on) keeps a notice per member in the same transaction (`account_deletion_notice`), told at once and hourly until each confirms (`tellMembersOfDeletions`). Learn's endpoints and code go through the module as on any register; nothing changes for Learn's apps. The sign-in with SimpleLogin is the register's (ADR 0020, step 2 built 2026-10-23: halkyon_base's `simpleLogin` endpoint on halkyon_oidc, the instance's SimpleLogin app as `SIMPLELOGIN_CLIENT_ID` and `SIMPLELOGIN_CLIENT_SECRET` in `deploy/.env`, the ways offered as `SIGN_IN_WAYS`), and so is its forwarding page, built ahead of it: `addSimpleLoginCallbackRoute` in `server.dart`, `/simplelogin-callback` on the web server, which Caddy forwards there, sending a sign-in on to the app by the marker its state begins with (halkyon_base's README); beside it `deploy/Caddyfile` answers Android's `/.well-known/assetlinks.json` for Halkyon Learn signed with the release key, on learn.halkyon.eu alone (`test/deploy/assetlinks_test.dart` holds it to `tool/release/android-certificate.sha256`). - **Switching devices' person** (halkyon_app's `useSession`, from the start screen): swap the session directly, never deleting the account signed in before. Someone signed in is never offered a switch: an address or SimpleLogin account that is another account's is said to be so. - **Email keeps only a keyed hash of the address** (ADR 0010, decided 2026-10-15): our own slim login (halkyon_base's `email.dart`) on Serverpod's code challenges and rate limiter, as Serverpod's email sign-in keeps the address in plain text and passwords. `email_identity` holds HMAC-SHA256 of the address in lower case, keyed with `emailSignInPepper`; the instance mails only an address someone has just typed, and the device remembers the address it signed in with (`SignInAddresses`, `sign_in.json` in the person's folder). A code by mail signs nobody in since ADR 0017 (build 22): it proves an address for a password's setup, a sign-up, a setup never finished, the resets and deleting an account whose second factor doesn't work, each code asked for one of those purposes (`EmailCodePurpose`), taken by it alone, its mail saying what it would do and to type it only in the app on this instance. Eight Crockford characters (halkyon_base's `short_code.dart`), 15 minutes, 3 tries, up to 3 valid at once for an address (a fourth voids the oldest); at most 3 codes an hour per address and network, 10 per address from every network and 10 per network. `emailCode.sendCode` (without a session) answers alike for every address. Refusals are `EmailSignInRefusedException` with a reason. Codes go by mail over SMTP (halkyon_base's `code_mail.dart`, naming the app the host configures, pub.dev's `mailer`, TLS only), through the service the passwords name (`emailSmtpHost`, `emailSmtpPort`, `emailSmtpUser`, `emailSmtpPassword`, `emailFrom`; in production `EMAIL_SMTP_…` and `EMAIL_FROM` in `deploy/.env`; learn.halkyon.eu's is Proton's), as plain text with the code and what it is for in the subject; without one, into the server's log in development, and elsewhere there is no email sign-in (`emailSignIn` in halkyon_base's `instance.basics()`, which the app asks). Nothing logs an address or a code (a failed mail logs its error's type), and never attach a handler to `package:logging`'s root logger, through which `mailer` would. - **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, halkyon_oidc_flutter since 23 Oct 2026, written for GitHub's sign-in and relicensed BSD-3-Clause by its author, SimpleLogin's alone since ADR 0020, D36): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before the provider answered. Ours waits for the provider, or 5 minutes, until cancelled; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. - **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc. - **The reference is `DevicesEndpoint`** (`apps/learn/halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment. - **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent. - **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows. - **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = readable, ADR 0002 rule 6; set for what a data key seals, ADR 0019) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read. - **Private studysets** (ADR 0019, decided 2026-10-21): a top-level studyset is private or not, with everything in its tree; new ones are private wherever the device holds the account's keys (a password or SimpleLogin brings them), copies from Explore too, and a studyset's options switch it (`setStudysetPrivacy`; the library offers once to make those from before private, every top-level one, its answer following the person in their settings, `privateOfferAnswered`, settings schema 11). Its data key, and the person's own key for their tags and settings, live in the synced `content_key`, sealed to the account (`ContentKeys`, `core/privacy/`). Every synced gateway seals and opens through one envelope (`PayloadEnvelope`, through `Privacy`, which knows each row's key: never from a column the instance controls, see below): XChaCha20-Poly1305 for the row's table, id and column and the row it belongs to where that decides its key (`bound`: a revision's `card:`, `note:` or `step:`, an answer's `step:`, a path's or a PDF's `studyset:`; sealed format v2), so a payload moved to another row, or pointed at another item, never opens, the payload holding the author's signature before the ciphertext; **every column that decides a key is bound into what is sealed or signed** (ADR 0019's final reading of 22 Oct): a new one is bound, or learned from the item's own rows before a write as studysets' are, and gets a case in the hostile-instance test; a path answer's `answer` holds its sealed form in base64. A new row's id is made on the device (`Privacy.newId`, UUID v7) so it is sealed for it before it is written. What doesn't open is `Locked`: a studyset shows Locked with Delete alone, its other rows read as blank, and a tag or settings row is left out so nothing folds or deletes it. Making a tree private or not, or moving a topic into another tree, rewrites its rows in place (`Privacy.rekeyTree`). The shape, size and rhythm of studying stay readable (columns, and the sync engine's change times), as the privacy page lists. **A private studyset's files** are sealed with its tree's key for their asset row before they are hashed (`AssetRepository` through `AssetSealing`, which `Privacy` does): the row's hash, the size and the upload (`encrypted`) are the sealed file's, its sealed payload names the file and the hash of what was sealed (asset schema 2), and the device keeps the sealed file to upload and the opened one, by its own hash, for what reads files by path; a file is a row per key. Every place adding a file names its studyset (`add`'s `studysetId`; the editors read `StudysetFiles`, which `_inStudyset` and the PDF reader set; accepted suggestions go by their item, `addToItem`), and rewriting a tree adds its files anew under its key, renames them in its content (whole values only) and blanks the old rows nothing else names. **A studyset's marker** (decided 2026-10-21), on the library's cards and its Share alike: a lock for private (published before or not), a shield with a warning while something in it is still readable or unsealed (Finish in its dialog), an open lock for readable and not published, a globe for published (`visibilityIcon`); the locks open the privacy dialog (`showStudysetPrivacy`, which also leads to publishing while not private), the globe the Publish sheet. **Against an instance that alters data** (ADR 0019's reading of 22 Oct): a key's sealed ID says what it is for (`ContentKeyId`: a studyset's key naming it, the person's, or a signed record that a studyset was made readable), so a studyset is private while the latest signed record says so, and the device keeps the latest it saw, every item's last key, the settings' newest versions (a row older than any isn't read; Count me folds off where any row says off) and unfinished rewrites in `privacy.json` in the person's folder (`PrivacyLedger`, never synced); a tree's key is the nearest studyset's that decides on its own (its record, private or made readable, or the key its own row was seen sealed with, learned from that row before a write; one sealed under a key not here refuses the write), read anew with the records before every write, and an item, which learns its key only from its own rows, is never written with a weaker key than it had unless this device moved it. A device holding the keys signs the readable rows it writes (the account's signature in `nonce`); a readable row nobody signed is `Locked` wherever privacy holds (`Privacy.openItem`). Rewrites run one at a time, are kept as under way, write the top studyset last, hold writes into their tree, and touch only rows under no key, the target key or one naming the studyset rewritten (the person's own moves excepted); `Privacy.maintain` (as the data opens, after each merge) finishes them and seals what a device that hadn't heard wrote. Cards of a private tree never move into a readable one (the app asks to make it private first, `PrivacyRefused`); a private topic moved out of its tree keeps a key of its own. A device without the keys of an account that has them writes no content at all (`keysMissing`, through `keyToWriteIn` and every item's write key): it opens no record, so every tree would look readable to it; that the account has keys is any sign, never taken back (`ContentKeys.accountHasKeys`). `test/core/privacy/hostile_instance_test.dart` holds all of it against an instance that alters rows: seeded runs of the person's actions on three devices interleaved with the instance's, checking after every step that nothing private when written is readable unless the person made it so, and that Count me is never on unless they turned it on last (50 seeds in `tool/check.sh`; `HOSTILE_SEEDS`, `HOSTILE_STEPS` for more). The full-window editors carry their studyset (`editRichText`), and where none is known an account with keys adds no file (`StudysetFiles.scopeOf`). - **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`/people//sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). **What is read is kept until it changes** (decided 2026-10-17; on a copy of a real library, 8,000 cards and 21,000 reviews, every screen's counts took over a second after each change, now about 65 ms once): `CardRepository` keeps each studyset's cards and every card's stamp until a card changes, `ReviewRepository` the whole log once many cards' reviews are asked for, updated by this device's writes and read anew after a sync brings others', and `StudysetProgressRepository` works out every studyset's progress once for the library, every studyset open and its topics. What everyone follows (the studysets, the settings, the tags, the progress) is a `LatestValue` (`core/async/`): a screen starting to follow gets the value last read, and only the first has it read, rather than every screen opening having it read again for every screen open; while nobody follows, nothing is kept or read. Work asked for again while it runs runs once more after it, not once each (`CoalescingRunner`, halkyon_core: syncs, uploads, working progress out), and a repository's watch reads so for each listener (`watchReads`); never write that loop by hand again. A read begun before a change never fills a cache (each counts the changes), and a cache keeps values, never futures. A test reading right after a change that reaches the repository another way (a fake remote change, a bloc's write still under way) lets the event queue run first. `SyncService` (halkyon_app) syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. The server never deletes them (it can't blank a payload; ADR 0007), and its publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced. - **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes. - **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), and the studied studyset's limits hold for everything inside it together, as Anki's do for a deck with its subdecks (decided 2026-10-17: a language's twenty topics started 20 new cards a day each), in the order the studied studyset's options ask for (reviews first, mixed, new first). **Exam plans** (`feature/studyset/model/exam_plan.dart`, decided 2026-10-14): a studyset plans for exams (`examPlans` in its payload, schema 7: each a name, the topics it takes whole by their ids, the tags whose cards it takes by their names), its author's published with it (the copier and the updater point the topics at the copy's studysets), and plans are offered from level tags too (`automaticExamPlans`: JLPT, HSK, KRDict's levels, each taking the levels below it, ids `auto:…`). The person's choice (`exam`: the plan's id and the exam's day, `YYYY-MM-DD`) is theirs alone, never published, like a PDF's deadline. `ExamCoverage` reads what a plan covers (its topics' studysets with their paths and PDFs, its tags' cards and learning paths anywhere in the studyset; a path's tags offer automatic plans as a card's do, decided 2026-10-17); the Study plan shows how far the person has come with it and the pace (`examPace`: what is new spread so that it is learnt a week before the exam, or halfway there when it is closer; new cards counted as the day began), and Study for the exam (`StudyFocus.exam`) studies what it covers alone, as one group whose new cards a day are that pace, reviews unlimited; once the exam's day is over, neither shows, and a session for it studies as usual. The library's and the topics' counts and badges add up the same way, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. Deleting a card deletes its reviews. - **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id. - **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `deletedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule), `write` (by hand, its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync. - **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces//`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: a person 500 MB or what a `file_allowance` row grants them, an anonymous account left from before nothing, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people. - **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the files' default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-` (unless the upload says it is encrypted, ADR 0019, which may be 4 KiB larger and isn't looked into). Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes. Beside the pages, cards are written in the composer's own editor (`CardDraftEditor`, with a `CardComposerBloc` of the panel's; decided 2026-10-12), every kind with its tags, hints and explanation, tied to the page shown and tagged with its chapter, and saved by Create, as the composer saves a card only once it is complete; the screenshot's button ends the format bar there (`InsertableBlock.inFormatBar`). - **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; a dictionary is one source in one meaning language (`jmdict` in `en`), of words or of characters (`kind`: JMdict, KANJIDIC2 with the kana), and the endpoints take it by those two, so a search for words never brings kanji; everything specific to a language (kana, romaji, meanings without "to") goes into each entry's `keys` and `looseKeys`, so the server's search knows no language: exact key, exact loose key, key starting with the query, loose key starting with it, each by the source's `rank` (`DictionaryEndpoint.search`, decided 2026-10-17), through `dictionary_search_key`, a row per key and loose key with its first one, two and three characters in columns of their own, indexed, so a key's start is an equality an index answers whatever the database's collation (Serverpod's models can't declare `COLLATE "C"` or `text_pattern_ops`, without which LIKE 'eat%' reads every row; JMdict's search went from 43–57 ms to 5–26 ms, at 1.4 million rows and a load twice as long), filled by the loader beside the entries; a dictionary loaded before it had rows is searched by LIKE over the entries' barred keys until it loads again (`hasSearchKeys`), as the server does as it starts, and those columns stay until a later release, as the server before the switch still searches them; text is looked up a word at a time through `dictionary_key`, a row per key without a space, indexed (`withKeys`: 20 ms for a line of subtitles in JMdict, where LIKE over the barred keys took 1.2 s), filled and analysed by the loader, which loads a dictionary again when its keys are missing. The server loads the files in its `dictionaries` folder at start and hourly (`DictionaryReloadFutureCall`), those whose SHA-256 changed, each source replaced in one transaction, one load at a time (`dictionary_loader.dart`). EDRDG's licence asks a server showing its dictionaries to update them at least monthly (decided 2026-10-09: instances keep current by themselves, weekly): the `dictionaries` service of `deploy/compose.yaml` runs the curator in a container of its own (`apps/learn/halkyon_learn_curator/Dockerfile`, user ID 10001; the AGPL tool never enters the server's image or process), `curate update --every 7` downloading EDRDG's files of the day (`JMdict_e_examp.gz`, `kanjidic2.xml.gz`; the JLPT lists are static, in the curator's `data/`), converting them and renaming each over its old file in the shared volume, which the server mounts read-only. A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`CardContent.entry`, card schema 6), so loading a newer version never touches cards. **Corrections reach cards by offer** (decided 2026-10-09): every entry has a `version` (16 hex digits of the SHA-256 of what cards take of it, made by the loader), a card keeps the one it took (`entryVersion`, card schema 8), and `DictionaryEndpoint.changed` returns, of the entries named with the versions cards keep, those that are another now. The card browser says how many cards are behind (`DictionaryCorrections`, `DictionaryUpdates`) and takes the corrections at a tap: `CardRepository.takeDictionaryUpdate` writes the entry as it is now over the revision in which the card last took it, as a revision of its own beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer. A card made before versions is never offered one. Every file carries its credits, which the app shows (About, and at the foot of the dictionary). In the app, a studyset's dictionary follows from its front language (`StudysetDictionary`, through `StudysetLanguageScope.of`): no attaching or detaching, and nothing dictionary-shaped shows without one (Add material › From the dictionary; Look up beside a vocabulary card's front). A word's example sentences (Tatoeba, through JMdict's examples edition) become its card's hints, up to three, the word in bold; Look up gives them to a card without hints of its own. `DictionaryAdder` makes the cards, each entry once in the studyset and everything inside it, archived topics too (decided 2026-10-16: a word a topic holds is held, so the Watch screen tags that card rather than adding another, and shows its progress), into where the person chose for the studyset (`addDestinations` in their settings, settings schema 10: the studyset or one of its topics not archived, chosen in `AddDestinationButton`, "Adds to N3", in the dictionary's app bar and the Watch screen's panel; the studyset itself while none is, or once the topic chosen is no longer inside it), a word's tags written with one read of the tags (`TagRepository.addTagsNamed`: every read takes in each card's tags, and a tag at a time made one Add take two seconds on 17,000 card tags), a word tagged `JLPT-NX` and with its word class by the first sense (`Noun`, `Verb`, `na-Adjective`); Look up in the composer gives a card the same tags (`DictionaryItem.tagNames`), and an entry looked up again on that card swaps the tags the one before brought (`isDictionaryTagName`). Where the instance also holds the language's characters, the dictionary screen has a tab for them (Kanji & kana: the kana, the JLPT levels and the school grades to add whole, each under its name), a character becoming a character card tagged `JLPT-NX`, `Grade N` or `Secondary school`, and `Hiragana` or `Katakana`; a character card's Look up searches the characters. Beside them a Radicals tab lists the 214 Kangxi radicals by their strokes (`feature/kanji/model/radical.dart`: the ideograph each of Unicode's Kangxi radicals stands for, the forms Japanese writes it as inside kanji, Unicode's English name), each listing the kanji KANJIDIC2 files under it (its classical radical, the label `radical:N`, which the curator writes), to add or see; that tab shares the characters' bloc, so what it adds shows as held in a search too. The curator stamps its files with how it converted them (`.conversion`, `conversion` in its `update.dart`): raise it whenever it writes something else from the same download, and instances convert again as soon as they run the new curator rather than up to a week later. **Languages besides Japanese come from Wiktionary** (decided 2026-10-14, in this order: German, Greek and English, then Chinese, then Korean): the English Wiktionary as Wiktextract reads it, kaikki.org's raw dump of every language in one file (its per-language files are being withdrawn), which `curate wiktionary` (and `curate update --languages de,el,en,zh`, `DICTIONARY_LANGUAGES` in `deploy/.env`, unset: those and Korean, `de,el,en,ko,zh`, empty: none) reads once, streamed: German, Greek and Korean with Wiktionary's English meanings (a Korean word with its Hanja and romanisation), English with what its translation tables give in German, the app's other language (`wiktionary-de-en`, `-el-en`, `-en-de`; a source per language, `wiktionary-de`, as the server holds one source per meaning language); each instance downloads it again whenever kaikki.org makes a new one (about monthly; it asks for the date first, `.wiktionary`), some 3 GB converted in ten minutes. Inflected forms, and Wiktionary's entries for forms alone, find their word without being entries; names, affixes, abbreviations, rare spellings and the articles inflection tables give cells of their own are left out, as they would match common words. wordfreq's small lists in the curator's `data/` (CC BY-SA 4.0, crediting SUBTLEX) rank the words and label the 15,000 most used `common`. A file may name an entry's `textKeys`, its own forms, which alone fill `dictionary_key` for it (a German word is never found in text by its English meanings; JMdict's files name none, so every key without a space fills it, as before). In the app, text is cut by its language (`WordCutting`, `text_words.dart`): Japanese by the longest words with its conjugations (`sentence_words.dart`), Chinese by its longest words as they stand (`chinese_words.dart`), Korean at its spaces, a word the dictionary lacks as it stands taken without the particle or copula ending it ends with (`korean_words.dart`: 집에서 is 집), other languages at their spaces, each word looked up as it stands, an entry written exactly so first (German's noun `Essen` before its verb `essen`; `spaced_words.dart`); a studyset's dictionary is of its front's language tag, else of its language alone (`zh` for `zh-Hans`). Every one of them takes Tatoeba's sentences as examples, so its words' cards get hints as Japanese ones do (`withTatoebaExamples`, `addTatoebaExamples`: the weekly per-language exports, each sentence with a translation going to the entries its words find, cut as the app cuts subtitles, up to three, the headword as written first; a dictionary whose sentences can't be had keeps its words). A Wiktionary word (`DictionaryWord.partOfSpeech`, in full, as `adj` and `adv` would read as JMdict's codes) shows its genders, principal forms, IPA and romanisation, a German or Greek noun of one gender goes onto its card with its article (`das Haus`, `το σπίτι`), its examples in its language become hints as JMdict's do, and its tag is its word class (`Noun`, `Adjective`, `Preposition`); a word is looked up on the web on Jisho in Japanese, on Wiktionary at its language's section otherwise (`wordSiteOf`). **Parity with Japanese** (ADR 0009, decided 2026-10-14): Korean's words come from KRDict instead (`krdict-ko-en`, the National Institute of Korean Language's learners' dictionary, CC BY-SA 2.0 KR, its JSON export monthly without an account), its levels (초급, 중급, 고급, labels `level:beginner` …) ranking and tagging words as JLPT and HSK do, a card's reading its pronunciation where it differs from the spelling (`[가치]`, not typed), the forms Wiktionary's tables give finding its words in text (the Wiktionary update keeps them, with the Hanja's native readings, in `.wiktionary-ko.json.gz`); characters come from Unihan (`unihan-hans`, `-hant`, `-ko`; the traditional ones those CC-CEDICT's words write, ranked by them; HSK 2025's character levels; Hong Kong's grades; Korea's 1,800 Hanja of education with their 훈 from Wiktionary, read 물 수) with the 40 Hangul letters as the kana are, and Greek's alphabet is the curator's own (`greek-alphabet-en`), so the characters' tab is named and grouped by the language (Hanzi, Hangul & Hanja, Alphabet; `characterGroupsOf`), radicals show in the language's own forms (`radicalFormsIn`: 讠 for 言 in simplified Chinese) and none for Greek, and a character's readings are named as its language names them (`characterReadingNames`: On and Kun, Pinyin, Eum and Hun). A Chinese word's pinyin is typed in a session (`PinyinInputFormatter`, `core/chinese/`: ni3 → nǐ), in a studyset whose front is Chinese only (`isPinyin` takes the language: a Japanese word read in romaji isn't pinyin). German is cut with its separated verbs (`german_words.dart`: a clause ending in a separable particle also asks for each word before it with the particle joined on, as subordinate clauses write it, `rufe … an` → `anrufe`; only a verb found so counts, and a capitalised word other than the clause's first, a noun, is never joined). The curator removes the files of languages no longer named or sources replaced, and the loader unloads every dictionary no file holds, once every file in the folder could be read. - **Watching from Jellyfin** (`feature/jellyfin/`, decided 2026-10-13; Animelon's way of learning from video): the person's own Jellyfin server, connected per device in Settings (a studyset's Add material offers From Jellyfin only once the device is signed in to a server, as the web search shows only once switched on, or once the instance suggests one; decided 2026-10-13) by name and password or a Quick Connect code; **the instance suggests a server** (`instance.info().jellyfinUrl`, decided 2026-10-13): its operator's `JELLYFIN_URL` (`none`: none; empty counts as unset, as Compose passes an unset one), else `https://jellyfin.` on the domain registered for its public host (`learn.halkyon.eu` → `jellyfin.halkyon.eu`, by Mozilla's Public Suffix List in the server's `data/`, `PublicSuffixList`, so an instance at `example.co.uk` never names someone else's `jellyfin.co.uk`; fetch the file anew from publicsuffix.org now and then), once that answers as a Jellyfin server (`JellyfinSuggestion`: looked at as the server starts and, asked an hour after, again in the background; the instance looks, never the app). The app asks for it once (`JellyfinSuggestion` in the app); the sign-in fills it in and finds it by itself, and lists the people a server shows on its login screen (`/Users/Public`): a tap names one in the form, which asks for the password, left empty for an account without one, as a demo's (Jellyfin v10.11 and later no longer say which accounts have one, `HasPassword` is always true, so nobody learns which are open; decided 2026-10-14); the token in the system's keyring with the device's Jellyfin id (`SecureJellyfinStore`, `JellyfinState`). Servers are reached over HTTPS only: an address typed with `http://` is refused before anything is sent, as the password and token would travel in the clear, except to a server on the same machine (`isLoopback`), whose traffic never leaves it. Which shows, films or videos a studyset is tied to follows the person in their settings (`jellyfinTies`, settings schema 9) as the server's and the item's ids alone, each named by asking the signed-in server when shown (`JellyfinTiesCubit`), so the instance never holds a title. `HttpJellyfinGateway` speaks Jellyfin's API (checked with v12.2.0, whose answers mix PascalCase and camelCase: fields are read in either); videos stream as stored (`static=true`, no transcoding), and text subtitles of any format come as WebVTT, which Jellyfin converts (picture subtitles, PGS and VobSub, can't be read). Videos play through `MediaPlayers` (`core/video/`): flutter.dev's `video_player` (ExoPlayer, AVPlayer) on Android, iOS and macOS, and media_kit's libmpv on Linux and Windows, used directly rather than behind `video_player`, which can't keep libmpv from drawing a video's own subtitles over the app's (its `VideoController` is made before the file opens: libmpv opens a file without a picture's output as sound alone, and the picture stays black; `integration_test/jellyfin_player_test.dart` checks a picture is drawn); not fvp, whose libmdk is closed. The sound is the track in the studyset's front language (`audioTrackIn`: files often mark every track default, so the language wins over the file's first; a commentary or a description of the picture only without another), found among the player's own tracks by its place and its language (`audioTrackAt`; libmpv names Japanese `jpn`, Android `ja`), and switched in the Watch screen's menu. The subtitles read are a whole track (`subtitleTrackIn`): never a forced one, which only covers what the sound doesn't say (signs, a film's signed scenes), and captions for the hard of hearing (SDH, closed captions; Jellyfin's flags, or the track's name) only without plain ones. The watch screen (`WatchScreen`, `WatchCubit`) shows the line of subtitles in the studyset's front language cut into the dictionary's words (`DictionaryRepository.wordsOf`: every stretch of a line that may be a word, as it stands and as every word it may be a conjugation of, `japanese_deinflection.dart`, asked for in one request, `DictionaryEndpoint.withKeys`, over the `dictionary_key` table the loader fills beside the entries and indexes; then the longest word from each place, kana only as words written so, `sentence_words.dart`; so the instance gets the text of every line looked up and keeps none of it, which the Jellyfin settings say, unless the device holds Japanese's dictionaries (below)), each kanji's reading above it (`furigana.dart`), the translation from the back language's track beneath, each joined into one line (files break lines for showing over a picture) and wrapping across the video's width, in a box of a fixed height (two rows of words with readings, two of translation; longer lines scaled down), so the video's size never moves with the lines (decided 2026-10-13); in the dark theme the video's colours glow around it as YouTube's ambient mode does (`AmbientGlow`: a 32-pixel copy of the picture every 3 s, stretched, blurred and fading towards the subtitles), and the words' panel takes the glow's colour as the scene changes (each copy's average, `averageColourOf`, no lighter than 35 % so the text stays readable, `panelColourOf`, as strong as the glow and fading as it does; in the light theme, which has no glow, a tint while the pointer is over it), and what lights up in it a scheme made from that colour (decided 2026-10-13). The player (`watch_player.dart`, decided 2026-10-13, as players are, YouTube's first) sets the picture in from every side with rounded corners, with its controls over it: play or pause in the middle, 10 seconds back and on beside it, along its foot where the video is (`0:05 ──── 23:47`) and a cog; they fade out after 2 s while it plays and the pointer rests (the pointer too), and come back as it moves, on a tap with a finger (a mouse's click plays or pauses), on a jump and while paused. The cog opens a panel inside the picture, so nothing runs off the window: Pause after each line, the subtitles read, the translation and the sound (tracks named by their language in the app's words, forced or for the hard of hearing beside), the keyboard shortcuts. The line's own buttons sit on either side of the subtitles they act on: the one before (or the one shown from its start once under way, as a music player's previous does; so no button for again, whose repeat sign means a loop in players) and the next. Keys are YouTube's (Space or K, J and L ±10 s, ← → ±5 s, 0–9 to a tenth, Home, End) with Shift+← → for lines, P pause after each line, Esc, and ? listing them; a word tapped pauses the video and is added with the dictionary's examples as its hints, never the line it was met in (decided 2026-10-13: Tatoeba's sentences are checked, a subtitle line may be wrong or mistyped, and every episode's line would pile up on a common word), tagged with the show and the episode ("あかね噺 S1 E1") as tags made for Jellyfin (`Tag.fromJellyfin`, `origin` in tag schema 2, written only for those; each found by what it stands for before its name, decided 2026-10-16: `JellyfinTagId`, the ids the show's or artist's item, asked for alone, has with the metadata providers and `jellyfin:`, and the episode's number or the song's own id, `jellyfin` in the payload, within schema 2 as a deliberate exception to ADR 0002 rule 3, since schema 3 would hide every new Jellyfin tag from v0.17; so a show the server names otherwise, in another metadata language or on another server, or renamed in Manage tags, keeps its tags, and a tag from before, found by its name, takes the ids; `DictionaryAdder.add`'s `jellyfinTagNames`), which one switch in Manage tags hides on cards being studied (`hideJellyfinTags`, settings schema 9; Tags and Manage tags list them faint meanwhile); the episode's words are studied or crammed from the Watch screen (its own tag given to the studyset's cards of them, those held before too), and while the studyset holds none of them the button adds the words listed instead (`addListed`), as its menu adds the new ones listed later; the video's words are listed, the most frequent first, particles left out, through a filter (decided 2026-10-13: new to the studyset, held, or all; JLPT levels; word classes by the first sense's grammar, `word_filter.dart`). The panel's entries (decided 2026-10-13) fold their senses to two lines, one sense to a line as Jisho lists them (as everywhere a dictionary lists words), and unfold over the list rather than in it, after 0.3 s under the pointer or on a tap (kept open then), in the video's colour, so nothing moves under the pointer; each links to the word on Jisho.org (opened only when tapped); a word the studyset holds shows how far its card is learnt (`EntryProgress`: New, Learning, Due, or Learned with when it comes again, in the theme's state colours); the dictionary's sources stay pinned in the panel's lower right corner. The longest match knows no grammar (今日は is read as こんにちは): kana of up to three characters that is no common word gives way to a shorter common one (が and まだ, not がま, a cattail, and だ), a longer kana word stands even when uncommon (もうろく, 耄碌, not もう and ろく; ichi.moe's segmenter likewise weighs long kana words by their length), and each word knows the longer words running over it into its neighbours (`SentencePart.wider`), which the panel offers beneath its entries, so what the cut missed is a tap away (decided 2026-10-13). Any stretch of a line's characters can be chosen by hand too (`LineWords`, `line_words.dart`, decided 2026-10-13: by characters, so also part of a word the cut took longer, 今日 out of 今日は): dragged across with a mouse (from the character the button went down on), Shift+clicked from the word or stretch chosen, or on a touch screen held on a character and dragged on; it is looked up as one word, as it stands or as a form of one, in one small request (`DictionaryRepository.wordOfStretch`, `stretchPart`; the instance has the line's text already), the line reads it as that word while it is chosen, and what the dictionary lacks as one word is offered as a search on Jisho.org. **Songs** are learnt from as videos are (decided 2026-10-13): a music library's artists and albums open as shows and seasons do, and can be tied to a studyset; a song plays as sound alone (`/Audio/{id}/stream`, as stored) with its lyrics as Jellyfin keeps them (`/Audio/{id}/Lyrics`, from an LRC file beside it or its tags; `JellyfinRepository.lyrics`), cut into words as subtitles are, with no translation: timed lyrics follow the song, the line sung large and bright among the others faded, scrolled to as it comes, a line tapped played from its start; untimed ones show whole. A song without lyrics shows greyed and doesn't open (Jellyfin's `HasLyrics`), as there is nothing in it to read. Its cover stands where the picture would, glowing as a video does; its words are tagged with the artist and "artist · title". **Japanese on the device** (ADR 0012, decided 2026-10-15): once the person agrees (the Watch screen asks once per device, with the download's size; Settings › Japanese on this device downloads, shows the room taken, removes), the device holds MeCab's IPADIC and the instance's JMdict (`DeviceDictionaries`, `/dictionaries/`, the device's, kept across people and instances) and cuts lines as Jisho.org does, their text never sent: MeCab (`mecab_for_dart`, compiled by its build hook) on an isolate per batch (`IsolateJapaneseAnalyser`), its tokens grouped by Ve's rules (`ve_words.dart`, written anew in Dart; に stays its own word), each word found as the longest match finds a stretch, else by its dictionary form, else cut into the longest words it holds from token to token, longer words over token boundaries offered as before (`analysed_words.dart`), looked up in a SQLite database the device builds from the instance's export (`DeviceDictionaryStore`: the same keys, ranks and versions as the instance's `withKeys`). The instance serves the curator's IPADIC pack (`curate ipadic`, every `curate update` run) and its JMdict export (made from its tables in its temporary folder as it starts and after each load) at `/device-dictionaries/`, named in `dictionary.deviceFiles('ja')` with their hashes; each file is checked against its hash, and once 30 days have passed the device asks for newer ones quietly and downloads only what changed. Without them, in another language, or should cutting on the device fail, lines go through the instance as before (`DictionaryRepository` asks the device first). - **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `/`. Calls to the bus go one at a time (`SerialQueue`). - **Citations** (`core/citation/`): a `Source` is of a kind (`SourceKind`, decided 2026-10-12: a book or an article, a website, a YouTube video, each asked for by its own form, from the Add menu of a list of sources; a video is cited with "[Video]" in APA, as a part of YouTube, and plays in the app where it can; studyset schema 6, step schema 4) and plain text parts as the person typed them (authors as full names, one per line; year; title; `container`, what it is a part of; publisher; ISBN; link; `raw`, a citation a file brought, shown while nothing else is cited), and `formatCitation` cites it in the device's `CitationStyle` (APA 7, MLA 9, Chicago 18 notes and bibliography, decided 2026-10-07): it splits and abbreviates the names itself (inverted names, particles, suffixes; names written with `&` or "et al." stay as written), sets a work of its own in italics and a part of something apart from the whole by `container`, and writes a missing year as "n.d." in APA and Chicago. A step's sources (step schema 2) and a studyset's bibliography (`bibliography`, studyset schema 4, last writer wins like the rest of its payload) are lists of them, shown and changed through `SourceList` and `showSourcesSheet`, in reference-list order (alphabetical, whatever order they were added in); the path import reads them too, text as `raw` and the old format's `citation` as `container`. - **What a card carries beside its type's fields** (card schema 5): up to six `hints` and an `explanation` (rich text, any type), a `report` that something on it is wrong (category, note, when; studied as before, its badge in the list), and a word's `senses` (glosses, label, JMdict's grammar codes, whose English short forms and meanings are data in `feature/card/model/jmdict_tags.dart`; with senses a word's meanings are their glosses, and `uk` on its first sense shows its reading from the start; wherever its meanings show, in a session, the card browser and the dictionary's lists, each sense's grammar notes follow its glosses small, grey and slanted, their meaning on hover: `VocabularySensesView`, `vocabularySensesSpan` for a list's row). Each merges as a field of its own. Deleting a card goes through `CardDeletions` (a `DelayedDeletions`, as notes and studysets), so the list and the session offer Undo. A session's round is one list in the order its cards come (decided 2026-10-16, `round_order.dart`): those done, the one shown, those to come; a card rated that is due again within the day goes back into it, after 4 more cards when rated Bad and after every card to come otherwise, and the rating buttons say so ("in 4 cards", "at the end", "now"), so the pills above the card follow the order the cards come in. A study session's Undo takes back its steps last first (ratings, burials and deletions alike, across its rounds), so it holds the cards it deletes (`hold`) until it ends, when they go after the usual time to undo; a card deleted in a session that never ended (the app killed) stays. Vocabulary can be quizzed as multiple choice (`quizVocabulary`, `quizAspect` in the study options, settings schema 5; `vocabularyQuiz` draws the wrong answers from the other words studied). **A character card** (`CardType.character`, card schema 7, decided 2026-10-09: one type for kanji and kana) holds the character on its front, its `meanings` (a kana's romaji is its meaning, so kana are studied and written as kanji are) and a kanji's `onReadings` and `kunReadings` as dictionaries write them (katakana; hiragana with the okurigana after a dot); its JLPT level and school grade are tags, its strokes the app's own. **How characters are written** (`feature/kanji/`, decided 2026-10-09: bundled, so writing works offline) is KanjiVG's for Japanese, in `assets/kanjivg/` (CC BY-SA 3.0, its notice in the licence list), and by language (`StrokeSet.of`, ADR 0009) Make Me a Hanzi's for Chinese (`assets/makemeahanzi/`, each stroke its median in KanjiVG's square, the Arphic Public License and the LGPL in the licence list; a data file beside the app, never in cards; `curate makemeahanzi`) and the curator's own Hangul letters for Korean (`assets/hangul/`, `curate hangul`; a Korean word is written letter by letter, `hangulLetters`): the curator's `curate kanjivg` writes one gzipped JSON file per 256 code points (2.7 MB in all), which `StrokeRepository` (app-wide in `Dependencies`, as the maps) reads when a character of it is first asked for; the strokes are SVG path data in a 109 × 109 square, read with `path_parsing` (pure Dart, so `CharacterStrokes` flattens them into points without `dart:ui`). `StrokeOrderView` traces them in order; `showCharacterSheet` shows a character with its stroke order, meanings, readings and components, from a studied character's answer and from the dictionary's characters. A studied character's answer also lists the words written with it (`example_word.dart`: up to five vocabulary cards of the studysets the session studies, whatever tags narrow it, read with its cards, so offline), each opening its card. **Writing by hand** (decided 2026-10-09, after Kanji Dojo's practice): the study option Write by hand (`StudyOptions.writeByHand`, settings schema 7) has character cards and words written in the session, those of a script whose strokes are drawn (Han characters, kana, Hangul: `hasDrawnScript`; a German or Greek word is studied as typed), mode `write` on a schedule of its own (words so written are neither quizzed nor typed). `WritingCanvas` takes raw pointers and claims every touch on it at once (an `EagerGestureRecognizer`), so a stroke never scrolls or swipes to rate; each stroke is checked as it ends (`checkStroke`, `kanji/model/stroke_check.dart`: both resampled to 22 points, shape compared once centred, position, length and direction, against what a loose hand strays by; a stroke closer to one still to come is out of order), a right one drawn as KanjiVG draws it, and the hand's offset from the middle forgiven in the strokes after. Mistakes are strokes that didn't count and hints asked for; two misses in a row show the next stroke faintly. `WritingBody` writes a word character by character, filling in those without a stroke order, and once written (or given up on, Show it) outlines the rating the mistakes suggest (`suggestedRating`: none Good, one or two OK, more Bad; given up, Bad). `stroke_check_test.dart` holds the checker to real strokes, moved, wavering, larger and smaller, reversed and out of order: change its weights only with that test passing. - **Imported cards** say where they came from in their first revision's payload (`origin`, card schema 3: `anki`, or `text` for flashcards pasted or read from CSV/TSV); the dashboard doesn't count them as written. The Anki import (`feature/anki/`) reads `.apkg`/`.colpkg` packages of every format on an isolate of its own (`AnkiPackageReader`), zstd through our fork of `pure_zstd` (Anki's frames don't record their size), and its fixtures come from Anki itself (`apps/learn/tool/anki/make_fixtures.py`). - **Every client migration keeps a device's rows** (`test/core/sync/device_migration_test.dart` replays each on a database with a row in every synced table of ours; SQLite rebuilds tables to add foreign keys, which Serverpod's runner does with foreign keys off). A new synced table needs a row in that test, which fails until it has one. - **Every synced table indexes `spaceId`** (`indexes: _space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables. - **The client's local migration** (in `apps/learn/halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device. - **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server, which Learn's configuration of halkyon_base carries; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (halkyon_app), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number. It is 24 since GitHub went (ADR 0020, D36): builds up to 23 (v0.20.0) read whether an account has GitHub (`SignInWays.gitHub`), which is gone, so **the server without GitHub deploys only together with the release of build 24** (as the server with the module did with build 21, ADR 0014); Learn's own `instance.info()` stays, with every field, as released apps read `minimumAppBuild` there. - **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (halkyon_app's instances, ADR 0014; Learn gives them `learnFrame`, its words and look). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`, under the wordmark on a cobalt wash: two tiles as Proton's account page shows its services, decided 2026-10-18, the build's own in one tap, with where it keeps people's data (below), and another instance, which unfolds the address to type, checked with halkyon_base's `instance.basics()`, which must name the app `learn` (`ServerpodInstanceChecker(app: 'learn')`: another Halkyon app's instance is refused), and only over HTTPS or plain HTTP on this machine (`Instance.isSecure`, ADR 0017: an address over plain HTTP elsewhere is refused before anything is sent there, and one kept from before is never opened, the app offering another instance or removing it; development builds keep their own over HTTP, as an emulator reaches the developer's machine as 10.0.2.2); `InstanceTile`: a ring from the primary colour to the tertiary, a glow and a tagline come in under the pointer, on the keyboard's focus, while pressed or open, and without a mouse the taglines always show), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` (`main.dart`) for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances//people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use. **While nobody is signed in, the app asks how to start** (decided 2026-10-16, `StartScreen`; without anonymous accounts since ADR 0020, D25): on the instance in use, with SimpleLogin or a password, or on another instance; an instance out of reach is said so, with Try again, and one offering no way to create an account too. A first start leads there once the instance is chosen, and so do signing out and a session the server ended. **Where the operator approves sign-ups** (ADR 0021, step 1, decided 2026-10-25: `SIGN_UP=approval` in `deploy/.env`, the start screen saying so first), a new account gets no session but halkyon_app's wait screen, with four words the operator reads out to the person to match before approving, and signs in once more once approved; whoever operates the instance gets the card This instance in Settings, right after the Sync card, and the count of accounts waiting on the Settings cogs (Home, Profile), leading to the operator's screen (approving, refusing, suspending, naming operators). The first operator is named by SQL (halkyon_base's README). An anonymous account's session, left from before, is deleted as the app starts. Settings names the account's instance ("Signed in on learn.halkyon.eu"). **Where an instance keeps people's data** (decided 2026-10-18): its operator says so in `deploy/.env` (`DATA_LOCATION`, `BACKUP_LOCATION`: `EU`, a country's code or a subdivision's, ISO 3166-2 such as `US-CA`, the backups also `none`, decided 2026-10-19; where the disks are, never which law governs, as a court reaches the operator wherever the server stands), halkyon_base's `instance.basics()` hands it on (Learn's `instance.info()` too), and the first start's tile and Switch account show it once the instance answers (`InstanceLocationBuilder`): the most specific flag of where the data is (`InstanceFlag`, from the `flag` package, which draws lipis' flag-icons: a subdivision's own where it has one, Scotland's, else its country's) and the words, as labels rather than sentences ("Data: European Union", "Data: Germany (EU)", "Backups: California, United States"), since "in" would need the article some countries take; a member country of the EU gets "(EU)" and a subdivision its country; countries are named by Unicode CLDR 48.2's names that halkyon_app bundles (`AssetCountryNames`, the same as the world map's, Hong Kong, Macao, Myanmar and Palestine by their short names), subdivisions by Unicode CLDR 48.2's, bundled in halkyon_app; `registerHalkyonAppLicenses` registers the flags' notice and that CLDR's, beside Learn's own for the map. **The instance's privacy page** (ADR 0021, D39, built 2026-10-25): halkyon_base's `/privacy` on the web server (`addPrivacyPageRoute` in `server.dart`, before the profile pages' route; Caddy forwards it), made from the instance's settings, says this and who may sign up, what the module keeps and what goes to an assistant, with Learn's own points after (`_privacyPagePoints` in `auth/learn_base.dart`, English and German): change them with what Learn keeps or sends. - **Searching the web and asking an assistant** (the web search decided 2026-10-10; `feature/assistant/`; the hand-off to AI assistants and Claude's connector went on 23 Oct 2026, ADR 0022): a device setting, off unless the person turns it on (Settings › Web search), shows a field at the foot of Home (`AssistantBar`) and a button that unfolds into one (`AssistantButton`): at the lower left of the Library, across from Create studyset, at the left above a studyset's bottom bar (floating alone at the lower left from expanded windows on), and at the lower left while studying, above the buttons that answer the card (decided 2026-10-16, as Vaia places it). Above the field, chips offer the parts of what it is about (`AssistantTopic.parts`; a search only those `searchable`: a card's front, back, each hint, its explanation, never a studyset's name or description nor a card's images), none chosen, so only what the person picks goes along, each cut at 600 characters. `WebSearch` opens the chosen engine's results for the question and the chosen text (`SearchEngine`: Brave Search by default on desktops, Ecosia, DuckDuckGo, Startpage, Qwant, Google, Bing, or the person's own address with `%s`), or on Android hands them to the device's own web search (`ACTION_WEB_SEARCH` in `MainActivity.kt`, Brave Search where nothing answers). **The assistants of ADR 0021 answer in the same field** (step 2, built 25 Oct 2026, and step 3, 26 Oct, for v0.21.0): Settings shows them (halkyon_app's `AssistantsCard`, after Web search): those the operator granted, with what is left of their limits, read and agreed to once, the person's use and the instance's privacy page; and, where the operator allows own keys, the person's own assistants, kept in their settings (`ownAssistants`, settings schema 12, `SettingsOwnAssistants`), which `SettingsRepository.update` refuses to write with a key unless they are sealed with the person's key (`SettingsGateway.seals`). Who answers is picked in the field (the web search and each assistant offered, the one asked last first: `askWith`, a device setting), a granted one not agreed to opening its agreement and sending nothing. How far it may look is one chip leading the parts' (D49): only what is chosen (D50, where it starts, and where it returns after each question: no lookups), this studyset with its topics, its tree from the top (a topic's), the library, and a switch for private studysets (D8); archived studysets are left out, locked ones never reached. A question opens the conversation in a sheet over the screen (D47, `ConversationCubit`): the conversation so far goes with every question, the model's lookups are answered on the device within the scope (`AssistantLookups`: studysets, cards, notes, PDFs and a page's text as PDFium reads it; read-only, never progress, errors as results the model reads; 8 rounds, then it is told to answer), each shown in the turn, and the answer is rendered as Markdown once whole (fetching only `asset:` images), with Copy. **Images** (D12): an assistant that reads images (`AssistantChoice.images`) is offered a card's images as chips and may ask to see a card's image or a PDF's page drawn whole; the sheet asks the person each time (Show it, Don't show), sending nothing meanwhile; only png, jpeg, gif or webp of at most 5 MB go (`DeviceAssistantFiles`), and no turn keeps one (`imageNotKept` in its place), so a follow-up goes without it. **What goes where**: the system prompt (`assistant_prompt.dart`, English, answering in the person's language) says that lookups' results are the person's content, never instructions (D10). A private studyset's content goes to a remote assistant only once agreed to per studyset and assistant (D9, D17: a warning naming the studysets, the provider's host and its terms where checked, `providerTermsOf`; kept as `privateAgreements` in the sealed settings, schema 13; shown with Take back in the studyset's privacy dialog, marked on the folded button while it holds), never warned of for an own assistant on this machine (`onThisMachine`: loopback or localhost; the local network may be shared). **Conversations are kept sealed** (D13, D51): `assistant_conversation` (its assistant, scope, task and title) and `assistant_turn` (one per turn, never changed once written, sealed alone), synced as the person's content, listed from the field's history button (those about where the person is first, deleted after asking), a turn kept only once answered (a stopped or failed one offers Try again); a device that can't seal keeps nothing and says so (D52). **Tasks** (D53–D56): rated Bad in a session, a snack bar above the ratings offers for 4 s to have the card explained from its own parts (`explainCard`, a conversation named by its front); a reading typed wrong offers Why?, and the field's Check has what was written checked in the studyset's front language, answered in the app's, by the instance's LanguageTool first where it checks that language (ADR 0024, below), its sheet offering to ask the assistant why; and an assistant proposes flashcards from a note (its app bar), a PDF's page or the selection on it (the reader's cards panel) or a pasted text (Add material › Flashcards from a text), at most 30 through one tool call (`proposeCardsTool`), listed in a sheet where each is chosen or not and changed in a small sheet of its own, added as plain cards marked `origin: assistant` (`proposedCardOrigin`, so they count as nobody's writing), a page's tied to it; nothing is added before Add (D10). Nothing on an instance reads people's content for an assistant (ADR 0022): the device looks up, and the instance only forwards the operator's assistants' requests. - **Spelling and grammar** (ADR 0024, decided 2026-10-26): where the operator runs the LanguageTool sidecar, the server's `grammar.check` (`requireLogin`; `lib/src/grammar/`) forwards a text and its language to it, at most 20,000 characters and 60 checks a minute per person (counted in memory), keeping and logging neither; `instance.info().grammarLanguages` names the languages it checks (asked as the server starts and an hour after; a tag maps to LanguageTool's code in `languageToolCode`: `en` and `de` to `en-US` and `de-DE`, which spell-check, an empty one to `auto`). In the app (`core/grammar/`), an editor given a `grammar` language (`RichContentEditor`, `CardFace`: a card's front in the studyset's front language, its back and answers in the back's, everything else recognised) is wrapped in `GrammarHost`, which checks while the device's setting is on (Settings › Writing, `checkGrammar`) and the instance checks the language: `GrammarCheckController` after a pause of 1.5 s and on blur, a paragraph at a time (`grammarSegmentsOf`, formulas and code masked), only paragraphs whose text changed, an edit dropping what was found at once, and `GrammarOverlay` drawing red wavy lines over the editor (nothing is written into the document), a tap offering the suggestions. In a private studyset it checks only once the person agreed for that studyset, asked once in a banner above the editor while it has the focus (the agreement in `privateAgreements` under `grammarAgreementKey`, a refusal in `grammarDeclined`, settings schema 14, both shown with Take back in the studyset's privacy dialog). The field's Check sends what was typed there to LanguageTool anywhere (`showGrammarCheckSheet`). - **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (halkyon_app, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card, beside whether a sync ever went through on this device (`syncedHere`, read as the person's data opens): until one has, an empty library says it is on its way from the instance rather than that there are no studysets (decided 2026-10-17: a first sync brings everything at once, at its end). The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on). - **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version, halkyon_base's too: upgrade them all together with the sync packages, halkyon_base and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on. Its shared part, `serverpod_offline_sync`, comes from our fork (the root `pubspec.yaml`'s `dependency_overrides`, `project-halkyon/serverpod-offline-sync` on code.halkyon.app, pinned to a signed commit of branch `halkyon/batch-inserts`, which stacks six branches): `halkyon/byte-payloads` (decided 2026-10-12: v0.0.10 compares byte payloads byte by byte through a UUID parse that throws for each byte, so one sync touching a few thousand cards blocked the server for minutes); `halkyon/inbound-merges` (2026-10-17: `onInboundMerge`, called only when changes from the remote peer were merged, which the app's sync uses; `onMergeSuccess` fires for a space only pushed to, so every repository read everything again after each save's sync); `halkyon/fast-deletes` (2026-10-17: a local delete skips the foreign key projection pass when it can't change it, `canSkipProjectionAfterLocalDelete`; the pass loaded nearly every synced row, 8 s for one delete in a library of 8,000 cards, 30 s for a card; not reported upstream yet, the developer's call); `halkyon/fast-merges` (2026-10-17: a merge only inserting new rows proven unable to change the projection skips its passes, `canLeaveProjectionUnchangedByMergedInserts`, 9.5 s to 60 ms for a rating another device made in such a library; and projections are rebuilt once after the synced tables changed, not on every session and merge, which fixes #150 below); `halkyon/fast-merge-deletes` (2026-10-17: a merge only deleting rows skips the pass when every visible row below them goes with them, `canLeaveProjectionUnchangedByMergedDeletes`: a card deleted on one device took the server 5.4 s to merge and each other device 4 s, now 35 and 99 ms); `halkyon/batch-inserts` (2026-10-17: such a merge writes its new rows up to 1,000 to a statement, and the insert proof takes a parent of the row's own table inserted before it, as nested studysets are: a first push of 8,000 cards took the server 35 to 41 s, now about 5; a device receiving it 60 s, now 44, as Serverpod's SQLite adapter still inserts row by row). Move the fork along when upgrading, and drop each change once a release has it. - **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository, `upstreamSourceCodeUrl` in `learn_base.dart`; halkyon_base's `sourceCodeUrl`) and the app's About dialog shows it next to the licences and every package's notice (halkyon_app's `showAppAbout`, asking `Dependencies.sourceCodeUrl`, with Learn's credits as its own parts, `feature/about/`). Keep it working when changing the dialog or the endpoint. - **Public profiles** (ADR 0005, stage 1 of the public tier; halkyon_base's since ADR 0016, decided 2026-10-19: the instance's register keeps them, the same in every service of the instance, so the module serves the endpoints below under `base`, the page and the link checks, and Learn's code reads people's handles only through the module's `profileOf`, `profilesOf`, `personWithHandle` and `fixHandle`, never the table, except Explore's search, which joins it in its query, so **Learn holds the register wherever it runs**): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`, which publishing sets through the module's `fixHandle` just before it writes the release), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own, and says why a handle couldn't be theirs (`checkHandle`, null when it can); `ProfilesEndpoint` finds anyone's by handle (`ProfileNotFoundException` for none, and for one whose person hasn't gone public yet: ADR 0020, D5, published nothing and left the profile empty; the page answers so too, while Learn's code still finds whose a handle is, `personWithHandle`). Every answer about a new handle tells that an account has it, so the module counts them together (`countHandleCheck`: checking, saving, signing up or setting first keys with one, and Learn's `inbox.block` and `unblock`, which look people up by handle), at most 30 an hour per person and 60 per network (`tooManyHandleChecks`, which the handle's dialog words), which the app shows in a sheet (`showProfileOf`, decided 2026-10-14) from a studyset's author in Explore and from a licence holder written as a handle, `© raisondetredev`, a link set apart from a name from elsewhere. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. **A profile has a page on the web** (ADR 0011, decided 2026-10-15): `https:///@handle` where the operator set `ACCOUNT_DOMAIN` (so the address belongs to the identity, which other services such as a future train.halkyon.eu may share), else on the web server (`profilePageUrl`); served by the module's `profile_page.dart` on the web server (`addProfilePageRoute`, `/*`, one segment, after the server's literal routes; its foot names the app as `learnBase()`'s `profilePageNote` says; Caddy forwards `/@…` on the instance's domain and on the account domain, which serves nothing else), in English or German, no JavaScript, `noindex`, every link `rel="me"`; asked for on another host, it redirects to its own address. **Links are verified as Mastodon verifies them**: `profile.checkLinks` (at most 10 an hour; the app asks after a save that brings an unchecked link, and on Check links) fetches each link's page (`SafeLinkFetcher`: HTTPS on 443, public addresses only, checked before connecting and connected to as checked, 3 redirects, 1 MiB, 10 s) and keeps in `verifiedLinks` those linking back with `rel="me"` (`linksBackTo`, pub.dev's `html`); a changed link loses its check, a new handle all of them. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it in a card of its own after the account's and the inbox's (each part a card, as Settings groups its own), as a form, for people with an identity, that saves as it is written (decided 2026-10-12): a field when it is left or Enter is pressed in it, a link when removed, never twice the same; the handle is chosen in a dialog that asks the instance while it is typed whether it is free, and choosing the first makes the profile with what was written (`PublicProfileBloc` takes saves one at a time, the latest of those waiting next, and a handle chosen holds for every save after it). - **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v` in a bare repository of the studyset's own, `/.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/.json`, `notes/.json`, `paths//path.json` with `steps/.json`, `pdfs/.json`, and the files the content names in `files/`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a studyset's `exam`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) for a private studyset or a tree holding a private row (`private`; ADR 0019), without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The person's tags are encrypted, so a release's tag names come from the device (`publish`'s `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 first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back. The library's cards say who made a studyset ("By you", or a copy's author by the handle in its `publishedAt`) and whether it is out on Explore (a lock or a globe, opening the Publish sheet through `showShareSheet`): `published()` lists the person's studysets published and not withdrawn, and `PublishRepository` remembers them in `published.json` in the person's folder (`PublishedStore`), so the library shows them offline too; publishing, withdrawing and looking one up update it. - **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anyone signed in) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `releasePart` gives out a release's JSON files in parts, at most 500 a part in the order of their paths (decided 2026-10-16: a language's thousands of words in one answer outlasted the client's 20 seconds on a phone), the first naming the version the rest ask for; the app fetches part by part, asking again for a part whose connection dropped, and takes an older instance's whole `release` when it has no parts. Both read through one `git cat-file --batch` run, whose answers are read while the object names are still being written: written first, a large release waited for ever once the pipes were full (fixed 2026-10-16; v0.17.0's server hung on Japanese). `file` gives each file by its hash. The gateway says what failed (`InstanceUnreachable`, `InstanceFailed`), and Explore says so: the instance not reached, its error, or the copy not written on the device. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs, as are the studysets a study button or exam plan names and the PDF a card, note or screenshot is tied to: whole values only, never text, `withOwnIds`, which the updater uses too, writing a release's PDFs before what is tied to them), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. **"Learn this here"** (ADR 0005, rule 8): `LearnerCountFutureCall`, daily, counts per published studyset the people of this instance with the `identified` scope who chose to be counted and studied a copy of it on 3 or more days of the last 30, fresh starts left out. The choice is `countLearning` in their settings (settings schema 8, off unless switched on in Settings › Privacy), which are encrypted (ADR 0019), so their device mirrors it to the instance after each sync (`LearningCounting`; `counted_learner`, server-only) and, only while it is on, reports which publications their copies come from (`learningCount.reportCopies`, `learning_copy`, each report replacing the last): the count reads no payload, and only a reported studyset in the reporter's own space counts. Only `learnersShown` (rounded down: 5, 10, 20, 50, …; none below 5) ever leaves the server, in Explore and the author's Publish sheet. **A copy is published within its licence** (rule 10): the server looks up the original's (`PublishRefusal.forkForbidden` under "no derivatives"; `licenseMustStay` under a licence that keeps adaptations under it, `keepsAdaptations`, in every studyset of the copy), and the release keeps what it is based on (`basedOn`, shown in Explore); a person publishing a copy of their own is its author, and the copy of another instance's studyset goes with its location alone. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is a grid of cards as the library's (decided 2026-10-14): the studyset's colour along the top (`colorName`, which a release keeps since then), its title under its secondary title, the start of its description, what it holds, then quieter its author's handle, the release and the licence, and Add (its progress, then In library, opening the copy; for its author Yours, opening their own studyset, which the instance names to its author alone, `ExploreEntry.yourStudysetId`, decided 2026-10-16, while it is in their library, the summary adding a copy anyway, to see it as learners get it); a tap opens a summary sheet with all of it. What Explore shows as the description is what its author wrote in the Publish sheet (Description on Explore, starting from the latest release's, else the studyset's own; `publish`'s `description`), so a studyset may introduce itself to strangers while its own stays empty in the library. - **Archives** (decided 2026-10-11; `feature/archive/`): a studyset with everything in it and its progress, as one `.zip`, imported from the Library's Import as the person's own (backups, moves between instances, a library brought from elsewhere). `archive.json` says the format (`{"format": 1}`; a newer one is refused whole); the studyset is laid out as a release (studyset.json, cards/, notes/, paths/, pdfs/, a directory per studyset inside, files.json and files/), so the release copier writes it (`ReleaseCopier.write`: unlinked, no `published`, its cards marked `archive` as imported ones are, returning each item's new id). A card may name a dictionary entry in place of its payload (`"entry": {"source", "language", "id"}`): the import makes it from the instance's dictionary as adding from it does (`DictionaryEndpoint.entries`, at most 1,000 ids at once), with the tags that brings; an entry the instance lacks leaves the card out, counted. `reviews.json` lists reviews of its cards by their ids in it (`card`, `rating` 1–4, `reviewedAt`, optional `durationMs` and `mode`), appended to the person's review log once the cards are in, so schedules go on from them. Reading runs on an isolate of its own (`ArchiveReader`; tests read inline). **Exporting** (decided 2026-10-15) is a studyset's Export content, a sheet offering Archive first, with the person's progress or without (a checkbox, on), then Markdown: `ArchiveExporter` writes it as the instance writes a release (keys sorted, indented, a directory per studyset by its id) but unlinked (`published` left out), dictionary cards whole, and refuses (`ArchiveFilesMissing`) when a file its content names can't be had now rather than writing one that looks whole; with the progress also its reviews and what a release leaves out as only its author's (the exam, a card's report, a PDF's deadline). v0.17.0's import refuses a file it doesn't know, so what it doesn't read rides in the files it does, as keys it skips, and the format stays 1: `archive.json`'s `tags` (colours, Jellyfin's marked), a `studyset.json`'s `archived` and `starred`, a step's `answers`, a PDF's `readings` (`ImportArchive` lists them); stars, the archive and tag colours come along without the progress too. `test/feature/archive/archive_export_test.dart` exports a library and imports it as someone else (whose fakes have made items first, so no id matches by chance), and holds the archive to what v0.17.0 reads. - **Reports and suggestions** (ADR 0005, stage 4, rule 11): `feedback_thread` (a report or suggestion: `publicationId` and `kind` columns, everything else in its payload: what it is about by release item id and version, a report's category, the text, a suggestion's proposed payload and the files it names), `feedback_message` (replies) and `feedback_decision` (an author's closed, reopened, hidden, shown, accepted, declined; the latest of each pair stands) are synced tables each person writes in their own space; a thread or reply names its thread by id only, as it may be another person's. `gatherFeedback` (`lib/src/feedback/`) assembles them for `FeedbackEndpoint.threadsOf` (`requireLogin`: public) and `InboxEndpoint` (`identified`: the inbox, unseen counts, `markSeen`, `block`, `file`) and decides what counts as it reads (see the ADR), since a modified app could write anything. `feedback_block` and `feedback_seen` are server-only; `deletePerson` deletes both. A release's commit credits the writers of suggestions accepted since the last (`Co-authored-by`). In the app (`feature/feedback/`): the card's Report sheet offers "Send to the author too" on a card from a release; a copy's ⋮ menu reports the studyset and lists its threads, as Explore's summary does; the editors of cards (⋮), notes and steps (app bar) suggest to the author (refused under "no derivatives"); the Inbox is in the Profile tab, with a count on the tab (`InboxCountBloc`, refreshed at start and whenever the inbox is read or acted on; no timer); a thread's sheet shows a suggestion beside the author's own item, its replies, and the author's decisions. Accepting writes the proposed content as the author's own revision (`FeedbackRepository.accept`), the writer's files fetched by hash. - **Discussions** (ADR 0013, decided 2026-10-15): questions on a published studyset, a topic in it or one of its cards, notes or steps, answered by anyone and commented on (under the question and each answer, never under a comment), as Stack Exchange has them, without votes: the asker accepts an answer, which comes first, the rest oldest first. Unlike feedback they are server tables (`discussion_post`, `discussion_seen`, `lib/src/discussion/`), written through endpoints, online only, so posts can be edited and ordered and the synced tables stay as they are: `DiscussionsEndpoint` (`requireLogin`) lists (by topic, item or title, 30 to a page, last active first) and reads; `DiscussionEndpoint` (`identified`, and a public profile) asks, answers, comments, edits, deletes, accepts, hides (the studyset's author), and keeps the inbox. A question names its target as the release does, and the instance keeps the topics it is within from git's file list (`ReleaseRepository.paths`, `topicsOf`), so a topic lists what is asked within it and below; one about the studyset as a whole names no item. Questions and answers are rich text without files (the composer's Insert menu has no media, and content holding images or sounds is refused: the person's files are theirs alone), comments plain text of up to 600 characters, 30 posts an hour. The author hides posts and blocks people with feedback's own blocks; a withdrawn studyset takes no new questions. A post with others' replies under it is never deleted, only emptied ("Deleted question"), with its writer's account too (`deleteDiscussionsOf` in `deletePerson`). In the app (`feature/discussion/`): a copy's menu opens the discussions within that studyset or topic (`RoutePaths.discussions`, over the tabs), a studied card from a release offers Ask about this, Explore's summary and the Publish sheet open the whole studyset's, and the Profile tab's inbox lists the discussions the person follows, counted on the tab with the feedback (`InboxCountBloc`). - **Deleting a person** goes through halkyon_base's `deletePerson`, in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then Learn's own data of theirs (`deleteLearnData`, `lib/src/auth/account.dart`, the hook Learn configures: their space with all its content through the foreign keys and its stored files, discussions, published studysets, blocks, what they read, their file allowance; their releases' repositories once the deletion holds), then the module's (their public profile with its avatar), then the person with their identities; an identified person's deletion also tells the instance's members (ADR 0016), the notices kept in the same transaction. What they wrote in discussions goes too, emptied where others replied under it (ADR 0013). A new table holding a person's data gets a line in that hook. The module's `account.delete` deletes the caller; the app also calls it for an anonymous account's session left from before (ADR 0020, D25), since nobody could ever sign into it again. - **Pruning**: `PruningFutureCall` (Learn's, calling halkyon_base's functions; people go through its `deletePerson`, so Learn's data of theirs too) deletes the anonymous accounts left from before, whatever their age (ADR 0020, D25), and forgets the resets that lapsed (ADR 0017), daily, scheduled when the server starts; and hourly the notes the rate limits make (`serverpod_auth_idp_rate_limited_request_attempt`; codes by mail, password sign-ins, SimpleLogin's sign-ins and mends and the answers about handles are counted per network, each note keyed by the visitor's IP address: `networkOf`, which behind Caddy reads the last address of `X-Forwarded-For` from the proxies `TRUSTED_PROXIES` names, as `deploy/compose.yaml` names Caddy, never the `Forwarded` header) once the limit's hour is past (`forgetOldSignUpAttempts`; email sign-in's notes too, and its codes past their time, `forgetExpiredEmailCodes`): Serverpod deletes them only for its own email sign-in, which this instance doesn't use, so they would otherwise stay for good, and the privacy page says the instance keeps no IP addresses beyond that; hourly too, the deletions the instance's members haven't confirmed yet (`tellMembersOfDeletions`, ADR 0016). Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first, and the daily pruning's under its name before D25 (`prune-abandoned-anonymous-people`). - **Coming back with a field focused brings its keyboard back** (`KeyboardBackOnResume` around the app): Flutter (v3.47.5) gives the window back the keyboard's height after the app was left while the keyboard moved, with no keyboard showing, which left screens pushed up ([flutter#191156](https://github.com/flutter/flutter/issues/191156), open). Remove it once that is fixed. - **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close. - **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them. - **Secrets** live in `apps/learn/halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out on every device. Never change `emailSignInPepper` either: every address's hash would be lost, and with it those people's way in. Never change `totpKey` (every authenticator stops working) or `recoveryCodeKey` (every recovery code), nor `signInDecoyKey` without need (it shows which addresses were unused to whoever watched). - **Checks on a device** live in `apps/learn/halkyon_learn_flutter/integration_test/` and run by hand, never in `tool/check.dart` or CI: `flutter test integration_test/ -d `. `pdf_engine_test.dart` checks the real PDF engine (on the Linux desktop too), `youtube_player_test.dart` the embedded YouTube player (Android), `jellyfin_player_test.dart` the video player streaming from a Jellyfin server (`--dart-define`s for the server and the person, in the file), `japanese_analyser_test.dart` MeCab as its build hook compiles it, with the curator's IPADIC pack and an instance's JMdict export (`--dart-define`s for both). Never on the emulator from an agent session: it takes the editor down with it. - **Every build needs a C++ compiler and make**, `flutter test` too: `mecab_for_dart`'s build hook compiles MeCab for the platform (ADR 0012; on Linux g++ or clang, on Android the NDK's, on Windows Visual Studio's, on macOS and iOS Xcode's), and `sodium`'s compiles libsodium from the source it ships with its configure script and make (ADR 0017, halkyon_keys and halkyon_app). The snap's build installs clang and make; CI's runner needs both too. - **Windows builds need Developer Mode**: building makes symbolic links (Flutter links each plugin's native code into the build, and pdfrx's build does too), which Windows lets ordinary users make only in Developer Mode. PDFium itself comes with the build as a Dart native asset. - **Linux builds need** `libwebkit2gtk-4.1-dev`, `libsecret-1-dev`, for sounds (`audioplayers`) `libgstreamer1.0-dev` and `libgstreamer-plugins-base1.0-dev`, and for videos (`media_kit_video`) `libmpv-dev`: the video player links libmpv, so the app doesn't start where `libmpv.so.2` is missing (the snap brings Ubuntu's `libmpv2` with ffmpeg's libraries, and its `cleanup` part removes every staged file the GNOME runtime or core24 holds at the same path, since older copies of cairo, pango or librsvg would hide the runtime's from its GTK; staging never runs packages' install scripts, so the snap makes the `update-alternatives` links for BLAS and LAPACK itself); that build also downloads mimalloc's source from GitHub (checked against its MD5) and links it in. If a Linux build tries to install to `/usr/local`, the CMake cache is stale: delete `build/linux`.