docs(adr): ADR 0021 accepted, the assistant's boundaries (D7–D30)

The developer's decisions of 25 Oct, reviewing ADR 0021 whole:

- What an assistant may see and do (D7–D12): the scope of each question
  (the parts ticked, the studyset, its tree, the library), looked up
  through read-only tools the app answers on the device; private
  studysets out of a wider scope unless switched on, and a warning
  whenever one's content would go to a remote model, Lumo included, as
  no provider keeps a question confidential while its model answers;
  changes only as drafts; progress never; text first, an image when
  ticked or asked for.
- What it keeps, does and where it lives (D13–D18): conversations kept
  sealed with the person's own key; four features (questions about what
  is open, cards from text, explaining a miss, checking a written
  answer); grants and limits in halkyon_base, the adapters in a package
  of their own (halkyon_llm, BSD-3-Clause); the proposal's points 4–8
  and 10 as written; the warning once per studyset and assistant; built
  for v0.21.0.
- Sign-ups and operators (D19–D26): SIGN_UP open, approval or closed;
  the first operator by SQL, others named in the app; suspending an
  approved account; a wait screen with a matching code the operator
  checks with the person; 14 days, 10 waiting.
- What people are told and what never goes (D27–D30): the app and a
  privacy page of each instance's own; others' posts only with their
  author's switch, a copy's licence being its agreement; an opaque ID
  per person for the provider.

ADR 0022 and Learn's AGENTS.md now point to it as accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 13:36:56 +01:00
commit c48d423619
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 153 additions and 19 deletions

View file

@ -103,7 +103,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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/<name>/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. 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.
- **Searching the web** (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 that a search takes (`AssistantTopic.parts`, those `searchable`: a card's front, back, each hint, its explanation; never a studyset's name or description), 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). Its names keep "assistant": the assistant ADR 0021 proposes, on an instance whose operator pays for it, is to answer in the same field. Until then Halkyon calls no language model, and nothing on an instance reads people's content for one (ADR 0022): with API keys on a public instance anyone could drive up the bill, and each person decides what a model sees.
- **Searching the web** (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 that a search takes (`AssistantTopic.parts`, those `searchable`: a card's front, back, each hint, its explanation; never a studyset's name or description), 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). Its names keep "assistant": the assistant of ADR 0021 (accepted 25 Oct 2026, to be built for v0.21.0), on an instance whose operator grants it, is to answer in the same field. Until then Halkyon calls no language model, and nothing on an instance reads people's content for one (ADR 0022): with API keys on a public instance anyone could drive up the bill, and each person decides what a model sees.
- **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.

View file

@ -1,7 +1,10 @@
# ADR 0021: A private instance: approved sign-ups and an assistant the operator grants
- **Status:** proposed 23 Oct 2026. Decided by the developer on 23 Oct: D1–D6 below; the
rest under *Proposal* is for review before building.
- **Status:** accepted 25 Oct 2026, to be built for v0.21.0 (D18). Decided by the
developer: D1–D6 on 23 Oct; on 25 Oct what an assistant may see and do (D7–D12), what it
keeps and does, where it lives, the proposal's points 4–8 and 10, how often it warns and
when it is built (D13–D18), who signs up and who operates (D19–D22), waiting accounts
(D23–D26), and what people are told and what never goes (D27–D30).
- **Builds on:** ADR 0016 (the register), ADR 0019 (private studysets), ADR 0020
(SimpleLogin), ADR 0022 (the hand-off and the connector gone).
- **Changes:** the rule of 10 Oct 2026 that Halkyon calls no language model with an API
@ -29,7 +32,15 @@ Two protocols reach nearly every provider: Anthropic's Messages API (Claude), an
OpenAI-compatible chat completions (OpenAI, Mistral, Google Gemini's compatibility
endpoint, OpenRouter, local models through Ollama, llama.cpp or vLLM). Proton announced a
Lumo API for autumn or winter 2026 without publishing its format; community projects
(lumo-tamer, an unofficial tracker) show an OpenAI-compatible interface.
(lumo-tamer, an unofficial tracker) show an OpenAI-compatible interface. Still none by
25 Oct, but for the unofficial tracker's "early access".
No remote provider keeps what it is asked confidential while its model answers: the model
reads plain text. Lumo too: its prompts are encrypted beyond TLS on their way to Proton's
servers, decrypted there to be answered and, Proton says, discarded; its zero-access
encryption covers the chats it stores, which an API call doesn't make. What keeps a
private studyset private there is a promise, not cryptography. Only a model on the
person's own machine does without one.
## Decisions (23 Oct 2026, the developer's)
@ -51,19 +62,131 @@ Lumo API for autumn or winter 2026 without publishing its format; community proj
- **D6 Personal keys only where the operator allows them** (over every instance, which was
recommended): an instance setting, off unless switched on.
On 25 Oct, what an assistant may see and do (the developer: "Only what is chosen, within a
studyset, within a tree of studysets, and a complete library (maybe even an option
including/excluding private studysets?)"):
- **D7 The person chooses the scope of each question:** the parts they tick (the field's
chips, as for a web search), this studyset (the one open, with everything inside it, as
studying it covers), its tree (from its top studyset down), or their library (decided
over a scope for a studyset without its topics). Beyond the parts ticked, nothing is
put into the request whole, as a library outgrows any model's context: the model looks
things up through tools the app answers on the device, read-only and within the scope,
each lookup shown in the conversation and counted in the person's limits.
- **D8 Private studysets stay out of a wider scope unless switched on** (recommended, over
taking them in with the warning, and over never sending them): beside the scope,
"Include private studysets", off for every question.
- **D9 A warning whenever a private studyset's content would go to a remote model**
(recommended): it leaves end-to-end encryption; the warning names the provider and links
its terms. The same for every remote provider, Lumo included (see *Context*), not
graded by promises the app can't verify, and with no exception for an address on the
local network, which may be a company's or a shared one; none for a model at a loopback
address, on the device itself. Shown for parts ticked as for the switch of D8.
- **D10 Changes only as drafts** (recommended, over answers alone and over writes with
Undo): the model proposes cards, notes or edits, which open in the composer or editor;
nothing is saved until the person accepts it, so neither a wrong answer nor
instructions hidden in a copied studyset (prompt injection) change the library.
- **D11 Progress never goes along** (over a chip for it, which was recommended): no
reviews, ratings, statistics or study time, in any scope.
- **D12 Text first, images when ticked or asked for** (the developer: "Can't it generally
be text, only images if necessary (e.g. graphs within a PDF)?"; then the recommended
way): text by default, a PDF's as PDFium reads it; where a part in scope holds an image
(a card's picture, a PDF page with figures), its image is a chip of its own, off; and
the model may ask for one, which the person approves each time. Only for an assistant
that reads images. The app can't tell by itself when an image is needed: a graph is
often drawn as lines, not stored as a picture.
- **D13 Conversations are kept, sealed** (recommended, over this device only and over
none): in a synced table sealed with the person's own key (ADR 0019, as their settings
and tags), so they reach all their devices and the instance can't read them, as Proton
can't read Lumo's stored chats; kept until the person deletes one.
- **D14 The first version does four things:** answers questions about what is open, in
the field on Home, in the Library, on a studyset and in a session (D7's scopes); makes
cards from text (a paragraph, a note, a PDF page) as drafts (D10); explains, in a
session after Bad, why the answer is what it is, from the card's own parts; and checks a
written answer or a sentence written in the studyset's language, saying what is wrong
and why, as feedback, changing nothing.
- **D15 The module and a package of its own** (recommended, over Learn alone first):
grants, limits, statistics and the forwarding are halkyon_base's, the register's, one
per instance as accounts are, so a person's grant and limits hold in every service of
the instance; the two adapters (5) are `halkyon_llm`, a pure Dart package, BSD-3-Clause
as `halkyon_oidc` is, in the monorepo, its publishing to pub.dev asked first; the field,
the scopes and their tools are Learn's.
- **D16 Points 4 to 8 and 10 of the proposal stand as written** (recommended, over going
through them one by one); what comes up while building is put to the developer.
- **D17 The warning of D9 is asked once per studyset and assistant** (recommended, over
once per session and every time), as explaining a miss and checking an answer go out
once per card: the answer is kept in the person's sealed settings, the studyset's
privacy dialog shows it with Take back, and the assistant's button in that studyset
carries a mark while it holds.
- **D18 Built for v0.21.0** (over shipping the batch held for it first, which was
recommended, and over later): the batch on `feat/remove-github` (GitHub's removal,
build 24) waits for it.
- **D19 `SIGN_UP` takes three values** (recommended, over approval on or off alone and
over invitation links now): `open`, `approval`, `closed` (point 1).
- **D20 The first operator by SQL** (recommended, over a one-time setup code, over a
handle in `deploy/.env` and over the instance's first account): the operator signs up
in the app, then, on the server, one statement written here makes that account an
operator and approves it. No code hands out the role, so nothing is there for a
stranger to guess; whoever runs an instance reaches its database anyway.
- **D21 Further operators are named in the app** (recommended, over SQL for each): an
operator names another by handle and can take it back; the last operator can't be
removed.
- **D22 An operator can suspend an approved account** (recommended, over deleting it too
and over grants alone): its sessions end and it waits again, nothing syncing, its data
kept, until an operator approves it anew. Deleting stays the person's own act, or the
operator's SQL.
- **D23 A waiting account shows a wait screen only** (recommended, over using the app on
the device until approved): it says the account waits for the operator, and offers to
delete it; nothing syncs or uploads, so a refusal loses nothing.
- **D24 A matching code tells the operator who waits** (recommended, over an
introduction line and over the handle alone): the wait screen shows a short code of
four words, and the operator sees the same code beside the handle, asking the person
(by phone, in person) what theirs says; a stranger who took a family member's handle
can't know it.
- **D25 The limits stand as proposed** (recommended, over 7 days and over 30): deleted
after 14 days unapproved, at most 10 waiting at once, sign-ups per network limited as
today's, a badge in the operator's app as the only notice.
- **D26 Someone refused may sign up again and wait again** (recommended, over refusing
their SimpleLogin identity for 30 days): nothing about them is kept; the cap and the
network limits hold strangers back.
- **D27 Each instance says what its assistants do, in the app and on a page of its own**
(over the app alone, which was recommended, and over the website's page alone, which
describes learn.halkyon.eu): the consent sheet when a grant is first used, the same
facts in Settings › Assistant at any time, and `/privacy` on the instance's web server,
made from its settings in English and German as the profile pages are, readable before
signing up (point 9).
- **D28 Others' posts go to a model only with their author's agreement** (the developer:
"not to send the content from others without their explicit (direct or indirect)
agreement to LLM providers"; recommended, over a licence for one's posts and over
never): a switch in the account's settings, "Others may send my posts to their AI
assistant", off unless switched on and shown on the profile; while it is on, their
posts in discussions and feedback threads are chips like the others, off. The rule
binds what Halkyon sends on a person's behalf: someone who copies text into a model by
hand acts on their own, and the app doesn't try to stop it. No standard says this yet
(25 Oct): the IETF's AI preferences are drafts naming training and search, not a
model's answer; Creative Commons' signals are still being worked out. Should one come,
the switch can be expressed in it.
- **D29 A copy's licence is its agreement** (recommended, over an author's switch per
studyset): publishing takes a licence that allows passing on (ADR 0005), so a copy is
the person's own to send.
- **D30 The provider learns an opaque ID per person** (recommended, over nothing): with the
operator's key, each request carries an ID the instance derives from the account with a
key of its own (OpenAI's `user`, Anthropic's `metadata.user_id`), never the handle, so
the provider can act against one person's abuse without blocking the operator's key,
and can't tell who it is.
## Proposal
1. **Who signs up, per instance:** `SIGN_UP`: `open` (as today), `approval`, or
`closed`. On halkyon.li `approval`.
2. **The operator** is a role on the register. The first one: `operatorSetupCode` in the
passwords; the sign-up that gives it becomes the operator, approved at once, and the
code counts once. A rule by handle would let a stranger who signs up first take the
operator's handle. The operator can name others.
2. **The operator** is a role on the register. The first one by SQL (D20), the others
named in the app (D21); an operator approves, refuses and suspends accounts (D22),
grants assistants with their limits, and sees everyone's figures, never content.
3. **Waiting accounts** (D1): after signing up (SimpleLogin, handle, keys: ADR 0020), an
account waits. Its sessions can only see that they wait, or delete the account;
nothing syncs and nothing uploads. The operator sees who waits in the app (the handle
and when they signed up, which is all the instance knows) and approves or refuses; a
refusal deletes the account. Unapproved after 14 days, it is deleted. At most 10
nothing syncs and nothing uploads (D23). The operator sees who waits in the app (the
handle, when they signed up, which is all the instance knows, and the matching code of
D24) and approves or refuses; a refusal deletes the account. Unapproved after 14 days, it is deleted. At most 10
waiting at once, and sign-ups per network limited as today's, so strangers can't flood
the list. The operator's app shows a badge; there is no mail, as the instance keeps no
addresses.
@ -93,16 +216,22 @@ Lumo API for autumn or winter 2026 without publishing its format; community proj
of the limit, forwards the request, and counts what the provider's reply reports
(input and output tokens). Past the limit it refuses (`limitReached`), saying when it
resets. A private studyset (ADR 0019) goes only as far as the person sends it,
decrypted on the device for that request.
decrypted on the device for that request. The scope (D7) holds the conversation: the
app runs the tools' loop, answering each lookup from the person's local data within
it, so with the operator's assistant the lookups' answers pass through the instance as
the question does. A draft (D10) arrives as a tool call the app opens in the editor.
Whether an assistant reads images (D12) is its setting (`ASSISTANT_CLAUDE_IMAGES`; for
a personal key, a switch beside it).
8. **Limits and statistics** (D3): per grant, per day and per month (a day's limit stops a
runaway loop early). Counted per person, day and assistant: requests, input and output
tokens, and the cost at the prices the operator enters per model (per million tokens;
without prices, tokens only, and limits in tokens). Neither prompts nor answers are
kept. The operator sees everyone's figures in the app, per person and month; each
person sees their own.
9. **The privacy page** names the provider, says what is sent and that the instance sees
it in passing, that the operator sees each person's figures, and that no content is
kept.
9. **What people are told** (D27): the provider and model, what is sent, that the instance
sees it in passing, that the operator sees each person's figures, that the instance
keeps no content, and that conversations are kept sealed on the person's devices
(D13).
10. **Personal keys** (D5, D6), where the operator switches them on (`ASSISTANT_OWN_KEYS`,
off unless `on`; `instance.info()` says so, and the app offers them only then): a
person adds a provider in Settings (protocol, base URL, model, their key), and may add
@ -112,12 +241,17 @@ Lumo API for autumn or winter 2026 without publishing its format; community proj
nor text and counts nothing, and the person's limits are their provider's. The
instance can't stop a modified app from doing so, and needn't: nothing passes through
it. Personal assistants are offered in the field beside those granted.
11. **Never sent, in any scope:** the password, the account's keys, the recovery phrase,
codes, API keys, the Jellyfin token, other people's data (but posts whose author
agreed, D28), and progress (D11). Nothing is asked without the person: no assistant
runs in the background.
## Consequences
- The operator pays the provider, and sees who used how much, never what for.
- The instance sees what is asked on its way to the provider; the provider's own terms
then apply to it.
- The instance sees what is asked on its way to the provider, and the lookups' answers;
the provider's own terms then apply to it. A private studyset leaves end-to-end
encryption wherever it goes to a remote model (D9).
- A provider that is down stops the assistants on it; the others granted, personal keys
and the web search go on.
- Strangers can sign up and wait, but can't use anything, and their accounts lapse.

View file

@ -5,8 +5,8 @@
- **Supersedes:** ADR 0006 (Claude reads studysets through a connector on the instance),
and the hand-off to an AI assistant outside the app (decided 10 and 14 Oct 2026, Learn's
`AGENTS.md`).
- **Followed by:** ADR 0021 (an assistant on the instance, on its operator's keys), still
proposed.
- **Followed by:** ADR 0021 (an assistant on the instance, on its operator's keys),
accepted 25 Oct 2026.
## Context