ADR 0021, D12: text first. Lookups find a studyset's PDFs and read a page's
text as PDFium extracts it. An assistant that reads 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 conversation asks the person each time (Show it, Don't
show) and sends nothing meanwhile. Images go only as png, jpeg, gif or webp
of at most 5 MB, are never kept in a turn, and a follow-up goes without them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The cards come as drafts (ADR 0021, D10, D55): one question with one
tool, propose_cards, the cards in a sheet, every one chosen at first,
each changed in a small sheet of its own or left out, and only those
chosen added, marked as an assistant's (origin "assistant"). They are
proposed from a text pasted in Add material, from a note's title and
text, and from a PDF's page or the text selected on it, tied to that page
(D56). A note or a page of a private studyset goes only once agreed to
(D9), the text pasted counting as the person's own; nothing is offered
without an assistant. PDFs give their pages' text (PdfDocumentView.pageText).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A conversation has a task (ADR 0021, D14): asking, explaining a card or
checking what was written, which the system prompt says and the history
shows. Rating a card Bad offers Explain in a snack bar lifted above the
ratings (D53); a reading typed wrong asks Why? (D54), and the field's
Check has the text checked in the studyset's language, the answer in the
app's (D54). Explaining sends the card's own parts, in a conversation
named by its front.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
ADR 0021, D9 and D17. Before a question sends a private studyset's
content to a remote model, through the parts chosen or a wider reach
with private studysets switched on, a warning names the studysets, the
provider's host and its terms (where checked: Anthropic, OpenAI,
Google, OpenRouter), and is asked once per studyset and assistant: the
agreement is kept in the person's sealed settings (schema 13,
privateAgreements, folded only where both rows hold it), shown in the
studyset's privacy dialog with Take back, and marked on the folded
button while it holds. Follow-ups and Try again ask again once taken
back, as the scope keeps where the parts came from (partsFrom). No
warning for an own assistant on this machine (loopback or localhost,
halkyon_llm's LlmEndpoint.onThisMachine); the local network may be
shared.
Texts that step 3 made untrue: the consent sheet now says what an
assistant looks up goes too and that conversations are kept encrypted
for the person's devices; the privacy dialogs no longer mention
showing a studyset to Claude; the privacy page says what is looked up
and when a private studyset goes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The field asks an assistant the instance offers as well as searching the
web (ADR 0021): who answers is picked at its start, the one asked last
first (askWith, a device setting), an assistant not agreed to yet asking
for agreement first. Asking an assistant, every part of the topic may go
along, and one chip leads them with how far it may look (D49), only what
is chosen until the person widens it to the studyset, its tree or the
library, private studysets only where switched on (D50, D8). The
conversation shows in a sheet over the screen (D47), half the height and
dragged up to the full: each question with what went along, the lookups,
the answer as Markdown once whole, follow-ups, Stop, and Try again for a
question that got no answer; a device that can't seal says it keeps
nothing (D52). The field lists the conversations kept (D48), those about
where the person is first, to open again or delete.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
ConversationCubit asks the assistant chosen each question with the
conversation so far (ADR 0021, D47), answers the model's lookups on the
device within the question's scope, eight rounds at most, the ninth told
to answer with what it found and the tenth ending the turn, every call
given its result so the conversation can still be sent; and keeps each
turn, sealed, once it is answered (D51): nothing of a question stopped
or failed, which can be asked again, and nothing at all on a device that
can't seal, which still answers (D52). The question and the parts sent
along are texts of their own, so a kept turn shows as it was asked, with
its lookups and its answer (TurnView).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The lookups a model may make in a conversation (ADR 0021, D7): the
studysets within reach, cards and notes found by what they say, a note
read whole; answered on the device, only within the scope the person
chose (D49, D50), private studysets only where switched on (D8), never
what is locked, never progress (D11). The question goes with each part
ticked, labelled, and the system prompt says how far the model may look
and that what it looks up is content, never instructions (D10).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The first part of step 3, decided as D47 to D52 in ADR 0021: where a
conversation shows (a sheet over the screen), where kept ones are found
(the field), the scope's chip and that it starts with only what is
ticked, how conversations are kept, and what a device that can't seal
does.
Two synced tables: assistant_conversation (its title, assistant and
scope) and assistant_turn (one question and everything up to its answer,
never changed once written, ADR 0001, rule 2), so two devices continuing
a conversation offline lose nothing. SyncConversationGateway seals every
row with the person's key, a turn bound to its conversation, and reads
no row that isn't sealed: a device that can't seal keeps nothing (D52,
ConversationNotKept). Deleting one blanks it and its turns first (ADR
0002, rule 2). A turn keeps every message a follow-up sends back (tool
calls and results, the providers' own blocks), but no image, only that
one was sent.
The instance's privacy page says conversations are kept, encrypted, and
what the instance sees of them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
ADR 0021, step 2's app half. halkyon_app asks the module's assistant
endpoints (ServerpodAssistantGateway, ServerpodAssistantOperatorGateway)
and a person's own provider directly (halkyon_llm), behind
AssistantRepository: an AssistantChoice is a GrantedAssistant, asked
through the instance, which counts it, or a PersonalAssistant with the
person's own key, which the instance never sees. assistant_messages.dart
turns a conversation into the endpoint's models and back.
AssistantsCard, for an app's settings: what is granted and left of its
limits, agreed to once on a sheet saying what goes where, the person's
use by UTC month, the instance's privacy page, and their own assistants
where the operator allows own keys (kept ones stay listed, unasked,
where it doesn't). AssistantGrantsCard on InstanceOperatorScreen: grants
by handle with their limits in the pricing's unit, changed and taken
back after asking, everyone's use by month; a found account's menu
grants too. Nothing shows where nothing is offered or can be read.
package:halkyon_app/assistant_models.dart holds the models in pure Dart.
Learn keeps own assistants in the person's settings (ownAssistants,
schema 12, SettingsOwnAssistants); SettingsRepository.update refuses
(OwnAssistantRefused) to write a key where the settings would not be
sealed (SettingsGateway.seals). Learn depends on halkyon_llm directly.
Todo wires the gateways; a member offers no assistant. No feature asks
an assistant yet (step 3).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Whoever operates the instance approves, refuses and suspends accounts
and names other operators in the app (D21 to D24, D34):
- halkyon_app: InstanceOperatorRepository on
ServerpodInstanceOperatorGateway (the module's instanceOperator
endpoint; refusals as OperatorFailed: notFound, isOperator,
lastOperator, notAllowed for 401 and 403, failed), in AppDependencies.
OperatingCubit counts the accounts that wait: asked as the person's
app opens, after every change, and every 5 minutes only while they
operate the instance, so nobody else's app keeps asking.
InstanceOperatorCard (or its tiles) and WaitingAccountsBadge show
nothing at all for anyone else. InstanceOperatorScreen, laid out as
Settings: the accounts that wait, each showing its four words to read
out before Approve (D33), Refuse for new ones after asking; who
operates, Take back after asking, the last always kept; Name an
operator by handle; Find an account by the start of its handle, with
Suspend after asking and Name as operator. English and German texts
("Admin" as the role word in German).
- Learn: the card right after the Sync card in Settings, which the
cogs' count leads to as their dot leads to Sync; the count on the
Settings cogs of Home and Profile.
- Todo: the tiles in the account sheet and the count on its button.
Todo's server is a member, whose `operating()` is always null, so
nothing shows there until it holds the register.
Tests: the gateway's mapping, the repository, both cubits with their
denials, the card, the badge and the screen; Learn's Settings shows the
card and the count to an operator and neither to anyone else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Settings built its cards as they scrolled into view and let go of those
far above. Scrolled back up, a card built anew asked again for what it
shows and grew once answered: Japanese on this device stays hidden until
the instance says what it offers devices, and Jellyfin's form fills in
the server the instance suggests and looks it up. Whatever was on the
screen was pushed down by as much, and near the top the list put right
where it had thought the first cards were.
Settings is short, so it is built whole now (`SingleChildScrollView`):
nothing is let go of on the way, text typed into Jellyfin's form stays
when scrolled away, and the scrollbar knows the list's length.
The new test scrolls a phone's Settings up from the bottom, 20 pixels at
a time, and holds every card to its place in the list; before, a card
moved 64 pixels. The citation test now scrolls to the dropdown it taps,
not to the guide's link below it, which only left the dropdown on the
screen while the cards beneath weren't built yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Every account signs in with SimpleLogin or a password now, so GitHub
goes from the module, the shared app package, Learn and Todo, ahead of
ADR 0017's GITHUB_SIGN_IN_ENDS.
- halkyon_base: no `gitHubSignIn` endpoint, GitHub identity provider or
GitHub refusals; no `githubClientId`/`githubClientSecret`, no
`GITHUB_SIGN_IN_ENDS` and no `forgetGitHubAccounts`; the register
loses `account-with-github`. `SignInWays.gitHub`, the basics'
`gitHubClientId` and `gitHubSignInEnds`, and `PasswordRefusal`'s
`signInAgain` and `noAddress` go. A first password takes a code
mailed to the person's own address; an account without one sets none
(`wrongCredentials`).
- Learn's server: `instance.info()` without `gitHubClientId`, the
pruning without GitHub, and `minimumAppBuild` 24, as builds up to 23
(v0.20.0) require `SignInWays.gitHub`: the server deploys with the
release of build 24.
- halkyon_app: no GitHub button or problems on the start screen, no
password offered after a GitHub sign-in, and `ServerpodAuthGateway`
without the browser half only GitHub used (`(client, base:)`). The
account section offers a first password only to an account with an
address. `LoopbackWebAuth` and the apps' schemes stay, SimpleLogin's
ways back.
- Learn's Profile lists no GitHub; the texts go (English, German).
- Serverpod's `serverpod_auth_idp_github_account` stays, as its
migrations make it; a row goes with its account, and ADR 0020's D36
gives operators the SQL to delete those left.
- deploy/: compose.yaml and .env.example without GitHub. The READMEs,
AGENTS.md files and ADR 0017 amended.
BREAKING CHANGE: builds up to 23 can't read the instance's answers
about an account's ways in; minimumAppBuild is 24.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A handle alone is private: its page and `profiles.byHandle` answer as
for a handle nobody has until its person fills in their profile or
publishes something. The hint under it said "Others find you as @…"
all the same, also after removing a profile (D27). It now says it is
private until then, by the rule the instance's `isPublic` applies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
CI failed on d5651c5b (run 165): the path editor's test waited 60 ms
for a save its 20 ms pause sets off, and on the runner, its CPU capped,
the timer had not fired yet. The code was the same that had passed on
the laptop.
- The path editor's test waits until the bloc has saved what was typed,
as the step editor's test does.
- The study session's test waits until the deletion held through the
session has gone, and the Linux Pomodoro platform's tests until the
alert, the registration and the suggestion have come over D-Bus,
through `eventually` (test/support), each where a fixed wait stood.
- Waits before checking that nothing happened stay as they are: a slow
machine can only make those pass more easily.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Every account keeps a handle now, so removing a public profile no
longer deletes it and frees the handle: it takes away what the profile
shows and leaves it private, as a profile is until its person goes
public (D5).
- `profile.remove` and the register's `removeProfile` (members ask the
register) empty the display name, bio, links with their checks and
the avatar, keep the row with its handle, and return what is left,
null for none. `/@handle` and `profiles.byHandle` then answer as for
a handle nobody has. Once the person has published, removing is
refused as before.
- Learn: the profile form starts afresh from what is left, "Remove
profile" shows only while something is filled in, and the dialog says
the handle stays.
- The module's README and ADR 0020 amended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Every new account gets a handle, and an account without one gets its
first as it sets a password. The handle is private until its person
goes public (D5).
- `passwordSignIn.signUp` takes a handle. `passwordSignIn.checkHandle`
says whether a sign-up could take one, only while that sign-up's
mailed code waits (`codeExpired` otherwise), counted per code request
(30 an hour) and per network (60) (D29). Members ask the register.
- `password.set` takes the first handle of an account without one
(D28); `SignInWays.handle` tells the app whether there is one.
- Each checks the handle before the code is used, so a refusal leaves
the code for another try. An existing account at the address keeps
its own handle where it has one, and the typed handle is still
checked, so the answer says nothing about the address.
- `first_handle.dart` holds what SimpleLogin, the keys and passwords
share: `refusalOfNewHandle`, `insertFirstProfile`, `hasHandle`.
- halkyon_app: one `HandleField` with its `HandleCheckCubit` (asked
300 ms after typing pauses, only about the handle typed last; the
instance's suggestion is free without asking) (D30). The password
setup asks for the handle between the phrase check and the code, and
a refusal at the code sends back to it. SimpleLogin's sign-up and
keys steps use the same field and now check as it is typed.
- The module's README and ADR 0020 (D29, D30, the built state) amended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The server's half of D25: every account has a way in, and the anonymous
accounts left from before go with the daily pruning.
- The module signs nobody in anonymously: `anonymousIdp` and
`gitHubUpgrade` are gone, and so is linking GitHub at the register.
- A password is set only by someone whose address is theirs (a
`firstPassword` code) or who signs in with GitHub alone (a fresh
sign-in and a `signUp` code); an anonymous account is refused
`wrongCredentials` before any code is used.
- SimpleLogin refuses to link onto a caller without `identified`
(`wrongCaller`), the member's endpoint too, so nothing turns an
anonymous account into one with a way in.
- `pruneAnonymousPeople` deletes every anonymous account left, however
new, never one identified. Learn and Todo schedule it as
`prune-accounts` and cancel the old `prune-abandoned-anonymous-people`
entry once.
- Learn: someone without `identified` keeps no files;
`FILE_LIMIT_ANONYMOUS_MB` is gone. `minimumAppBuild` is 23, as builds
up to 22 start with an anonymous account.
- Learn's sync tests make their person and session by SQL in the
development database, as Serverpod stores sessions.
- Docs, store listings and ADRs 0010, 0014, 0016, 0017 and 0019 amended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Every account has a way in, so the apps make no anonymous account any
more. The start screen shows whenever nobody is signed in, a first
start included, and offers SimpleLogin, a password and GitHub for those
who have it, with no start without an account. An instance out of reach
is said so, with Try again, and one offering no way to create an account
too. An anonymous account's session left from before is deleted as the
app starts (D25), falling back to signing out on the device.
What only anonymous accounts needed goes: signing in anonymously, the
signed-out mark, adding GitHub to an account and switching to a GitHub
account from the Profile tab (with its Cancel, the only one), an
anonymous device's own keys, the warning that signing out deletes the
account, and the texts about keeping progress. Settings says on which
instance the account is signed in. Learn's publishing, reports and
suggestions and its learner count no longer ask whether the person has
an identity: everyone signed in has one.
The server still offers anonymous sign-in until the next commit, so
older apps keep working until v0.20.0 raises the minimum build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
halkyon_app reads where the device's person stands with SimpleLogin
(nothing to show, linked, or missing) and shows it in SimpleLoginSection,
which each app places above the password part: Add SimpleLogin, once
added one more way in. GitHub is added to nobody where SimpleLogin is
offered, and Learn's Profile offers an anonymous person SimpleLogin
alone there (D23).
No cut-off day, reminder or banner (D24): with the developer the only
person on learn.halkyon.eu, the cut-off is the operator taking GitHub's
OAuth app out of the deploy's settings once that account has SimpleLogin.
ADR 0020 records it and drops step 5.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Where the instance offers SimpleLogin, the start screen offers it first,
saying that the instance keeps only the SimpleLogin ID, and opens a sheet
that waits for SimpleLogin's page in the browser (Cancel ends the wait),
then: for someone new, the choice between creating an account (the
suggested handle or another, checked with the instance, the password
twice, the recovery phrase shown once and three of its words typed back,
nothing reaching the instance before) and linking theirs again with its
handle and recovery phrase; for an account, its password opening its
keys; for an account of before, the keys made with the handle it lacks.
SimpleLoginCubit holds the steps, each refusal shown where it is mended
(a handle's on the handle, a sign-in to begin again on the browser's),
and closing the sheet stops waiting on the loopback.
The suggested handle is never checked: the instance suggests none that
anybody has, and every answer about a handle counts against its limit
(D5). The fake's handle rule is now the module's.
Learn and Todo build it with SimpleLogin's browser (halkyon_oidc_flutter,
now a direct dependency of both, with their names on the loopback's
page), their schemes and their loopback ports (43823, 43824), as the
instance's forwarding page has them. Their tests offer no SimpleLogin;
halkyon_app's test the sheet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A handle's page and profiles.byHandle answer as for a handle nobody has
until its person goes public: a handle fixed by a publish, or a display
name, a bio, a link or an avatar (isPublic). A person's own profile, and
the host's own lookups (personWithHandle), are unchanged.
So every answer about a new handle now tells that an account has it,
and countHandleCheck counts them together on the register, 30 an hour
per person (or attempt) and 60 per network, refused beyond with
ProfileRefusal.tooManyHandleChecks: profile.checkHandle and saving a
profile with a handle new for the person, simpleLogin.checkHandle and
signUp, keys.set with a handle, and Halkyon Learn's inbox.block and
unblock, which look people up by handle. A person's own handle and what
is no handle aren't counted. The SimpleLogin limiters of commit 3 give
way to it, so a network's 60 are shared between people signed in and
people new. The register's checkHandle, saveProfile,
signUpWithSimpleLogin and setFirstKeys carry the visitor's network from
the member that asked.
Halkyon Learn's handle dialog words the new refusal (English, German).
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The developer decided on 23 Oct to drop the hand-off to an AI assistant
outside the app, and Claude's connector with it, in v0.19.0, the app and
the server together: build 22 raises minimumAppBuild anyway, so no app
calling the connector's endpoints uses the instance after it. An instance
is to have one way to an assistant, its operator's (ADR 0021, proposed).
The app: the field at the foot of Home, and the button in the Library, on
a studyset and while studying, search the web alone, through the engine
chosen in Settings or Android's own search, with the parts of a card the
person picks; a studyset's name and description are never offered. Gone:
the assistants a question opened in (AiAssistant, AssistantHandOff), the
switch between them and the web, Settings' choice of assistant, Connect
Claude with its route, cubit, repository and gateway, and their texts. A
settings file from before keeps the old keys until the next change of a
setting writes it anew; nothing reads them.
The server: lib/src/assistant/ (MCP, its OAuth, the five tools, connect
codes and tokens), AssistantEndpoint, the daily forgetting of the
connector's secrets (its scheduled call cancelled at start), its lines in
the deletion hook, instance.info().assistantConnectorUrl (nullable, so an
older app reads it as none), and Caddy's forwarding of /mcp, /oauth/* and
/.well-known/oauth-*. The migration drops the connector's five tables,
children first:
DROP TABLE "assistant_token" CASCADE; -- tokens of a connection
DROP TABLE "assistant_grant" CASCADE; -- OAuth codes of a connection
DROP TABLE "assistant_connection" CASCADE; -- a person's connected client
DROP TABLE "assistant_connect_code" CASCADE;
DROP TABLE "assistant_client" CASCADE; -- OAuth clients registered
No other table references them, so CASCADE drops only their own foreign
keys. For the minute between the maintenance run and the switch, the old
server answers the connector with errors and fails to delete an account
(its hook still deletes from these tables); ADR 0022 says so.
BREAKING CHANGE: a connected Claude gets no answer from the deploy on, and
the app no longer opens a question in an AI assistant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Seed 218 had the person make a studyset private on the laptop while the
laptop, kept by the instance from the phone's make-readable, showed it
private already: the app offers only Make non-private there, so no record
of it came. Make private and Make non-private now follow the acting
device's own view; the laptop held at its earlier record is the residual
risk ADR 0019 names.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Found by the hostile-instance test (seed 186): the instance withheld the
laptop's personal key from the phone, so the phone wrote Count me off in a
row of its own beside the laptop's on; then it deleted the phone's row and
handed the key back, and the phone read the laptop's on alone, neither
row's version newer than the other's. A row a device saw as newest that is
gone, without this device folding it and without a row covering it (as
another device's fold always leaves), now leaves out every row it doesn't
cover: Count me falls back to off rather than to what the missing row
outweighed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Seed 77 found the phone showing Count me on after the laptop turned it
off: the instance withheld the laptop's row from the phone and handed back
the phone's own older one. That is a device held at an earlier genuine
record, ADR 0019's residual risk; what holds is that no device goes back
behind what it saw itself, which is what the test now checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Half of its card revisions pointed elsewhere are sealed ones pointed at a
card of another tree, the final reading's H1, which a choice among all
revisions and cards made rare.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The final reading of 22 Oct: rather than one reading at a time finding a
variant, a seeded test plays the instance. Each run interleaves what the
person does on a laptop, a phone and a device without the keys
(studysets, topics, cards, notes, paths, steps, answers, PDFs, files,
moves, making private or readable, tags, Count me, Finish, publishing,
reading back) with what an instance that alters rows does to the local
database, as a sync would bring it (unsigned columns changed, rows
replayed under their ids, revisions and answers pointed elsewhere,
unsigned rows planted and signed ones copied from elsewhere, key records
withheld, handed back or forged, tags linked, files withheld, settings
folded away and handed back, the register answering no), each followed by
the upkeep after a merge. After every step, against a model kept from the
person's actions alone: nothing private when written is readable in any
row kept or file stored unless the person made it so, a release sends
only the tag names shown, and Count me is on only if the person turned it
on last. 50 seeds of 40 steps (about a minute) on the real gateways over
a local database; HOSTILE_SEEDS, HOSTILE_STEPS and HOSTILE_FROM run more,
or one seed again, and a failure names its seed and every step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Each was found by the hostile-instance test (next commit) and has a test
of its own:
- a write decides on its key from the records and the tree read anew: a
studyset missing from a tree read before, or moved since, looked
readable;
- each studyset on the way up decides on its own first: its own record,
private or made readable (a readable studyset the instance moved under a
private one was taken as private, so a private card moved in and was
opened with the other tree), or the key its own row was seen sealed
with, learned from that row before a write (a topic the instance moved
to the top level was written readable on a device that never read it);
one sealed under a key not here refuses the write (a device that never
saw a studyset private, its key record withheld, wrote into it
readable);
- settings keep the newest versions seen: a row older than any of them,
or than another read with it, isn't read, so withholding a newer row and
handing back an older one rolls nothing back; Count me folds off where
any row says off;
- the upkeep reads only readable tags and settings, and a PDF read by its
id never refuses as a write would;
- two queries passed nullable ids to `id IN`, which Serverpod refuses at
run time: a path's step revisions and answers couldn't be read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Such a device opens no record, so every tree looks readable to it: it
wrote cards, notes, paths, steps, answers, PDFs and files into private
trees readable. Every content write now asks for its key through one place
that refuses there (keyToWriteIn, and each item's write key), before any
row is inserted; reading is unchanged. That the account has keys is kept
once this device saw key rows, so withholding them later and answering no
changes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
From the final reading of 22 Oct:
- a sweep loads only the rows not under the tree's key already, and opening
yields to the event loop every 64 rows, so a large tree never holds the
screen;
- a write waits only for a rewrite of its own tree, and reading waits for
none (it only decides what is trusted, which the records settle);
- a ledger write that failed keeps its changes for the next one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The final reading of 22 Oct found that Finish's question said only how
many edits of each kind there were, so the person couldn't see content the
instance planted, and that taking them in sealed whatever was unsigned when
it ran, answers included without being counted. The dialog now lists each
edit with its kind and what it says (answers too), and taking them in seals
exactly the rows listed, each only while it still says what was shown; one
that came or changed after the list stays locked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The final reading of 22 Oct found that the names were worked out again
after the sync that comes before publishing, so a link to a private tag
the instance merged in during that sync sent a name nobody saw going. The
sheet now hands what it showed to Publish, and the device refuses before
anything goes out when the names worked out then differ
(PublishProblem.tagNamesChanged), reading them again for the person to
look at.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The final reading of 22 Oct found that an item learned its first key from
any sealed row pointed at it: the instance pointed a revision of private B
at a card of private A, the card's next edit was sealed under B's key, and
making B readable opened it. Every sealed payload and every signed readable
one is now bound to the row it belongs to where that decides its key (a
revision's card, note or step, an answer's step, a path's or a PDF's
studyset; sealed format v2, the old dropped as nothing was released), so a
re-pointed row doesn't open and reads as locked, and an item learns its key
only from its own rows, before any write decides on one. A studyset's
parentId isn't bound (a move and an edit on two devices merge column by
column); instead each studyset on the way up keeps its own key before any
above it counts.
Also from the reading:
- whether the account has keys fails closed: any sign counts (keys open,
the register's yes, key rows, records, sealed tags and settings), the
register's no is never kept and it is asked again;
- a pending rewrite keeps whether it may take in unsigned rows, a sweep's
never does, and a sweep never takes over the person's own rewrite;
- settings carry one version vector per person, rows folded away here are
never read again, a row never seen is read only if it brings news, and
rows without a vector (HLKS) are dropped;
- a tree stays partly readable while rows a sweep can't move remain, which
are looked at again once more keys open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Finishing a partly readable studyset counts the edits a device without
the keys wrote readable while it was made private, by kind (flashcards,
notes, learning paths, PDFs, studysets), and asks: taken in, they are
sealed with the tree's key as the person's; left, they stay locked (the
short reading of 22 Oct, finding 11). In English and German. Tests on the
real gateways both ways, and of the dialog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The list awaits the names once as it is shown and says nothing where they
can't be had; the previous commit's test of that case failed, and the
analyzer flagged Future.catchError.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Before Publish, the sheet shows the names of the tags that go with the
release, which the instance learns, sorted, or that none go; none where
they can't be had, as something in the tree is private (the short
reading of 22 Oct, finding 5). In English and German.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The card's, note's or step's own id goes with a suggestion, and a sealed
file goes along readable only where it is under the key of that item's
tree: a file of another private tree, named by a row the instance
planted, is left out and never uploaded readable (the short reading of
22 Oct, finding 12).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Run on a1adc18, every test of a fixed finding failed but two: the replayed
sealed row now comes back under its own id, as the instance would replay
it (a copy under another id never opened), and the pending make-readable
now watches the files, as the old code's sweep sealed its rows again
after opening them. The older readable record against a newer private one
passes there too: it guards what was right already.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
A rewrite or sweep changes only rows under keys whose signed ID names the
studyset rewritten (or none), so making a studyset readable never opens
another tree's rows the instance moved into it, and a sweep never seals
them under its own key; the person's own moves alone re-key rows of other
keys. An item's key is never replaced by an ended key, nor by another
studyset's while the one kept holds, and is written with before the
tree's. A pending rewrite the latest record no longer asks for is
dropped. A file that won't move is counted and owed, the tree partly
readable meanwhile, and each step of the upkeep fails alone. Settings
carry a version vector (each device's count of writes): a row is refused
as a rollback only when it brings news from no device. Sweeps look rows
up by their key and skip those known unmovable; a sync is asked for only
when something was written. Rewrites run one at a time and write back
only rows unchanged since they were read. A record is made later than
the latest known, whatever the clock. An unreadable ledger is kept aside
and nothing private is written until the keys are opened again; whether
the account has keys is the register's to say.
Tests (each the reading's probe): a moved card under a studyset made
readable, a sweep after a move, a replayed sealed row under an ended key,
a pending make-readable over a later private record, a file that won't
move, two devices' settings at once, an older readable record on a fresh
device, a clock behind; the withheld-record test withholds the tree's
record alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The reading of 22 Oct, on the device's side. Private cards never move
into a readable studyset: the card browser asks to make it private first.
Locked studysets are left out of the move sheets and the destinations
words are added to. A studyset whose key isn't on this device says to
sign in with the password here, not that it is lost, and can still be
archived; creating one there says why it can't. The marker shows the lock
for a private studyset published before, and a shield with a warning
while something in it is still readable or unsealed, with Finish in its
dialog. The library's offer covers every top-level studyset, Recently
deleted and orphaned ones too, says what couldn't be sealed, and its
answer follows the person in their settings (schema 11).
The full-window editors carry the studyset they were opened from, and an
account with keys adds no file where none is known (a new studyset's
description takes pictures once it is made). A file no live row names is
never uploaded, and deleting a row clears what it owed. Sealed rows are
indexed by key and hash. A suggestion from a private copy uploads
readable copies of its files for the author; accepting swaps whole
`asset:` values only.
Docs: ADR 0019's "The reading (22 Oct 2026)" with the decisions, what
changed and the residual risks; "As built" and Learn's AGENTS.md brought
up to date.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The reading of 22 Oct, against an instance that alters data. A data key's
ID now says what it is for (a studyset's key naming it, the person's key,
or a record that a studyset was made readable), and the ID is sealed with
the key and signed by the account, so the instance can't relabel a key or
make one up; the row's own columns are no longer trusted. A studyset is
private while the latest signed record about it says so, and the device
keeps the latest it saw in a ledger in the person's folder (privacy.json),
so a record withheld or a row replayed changes nothing; a tree's key is
the nearest studyset's with such a record, itself or above. An item keeps
the key it was last seen sealed with unless this device moves it or a
signed record makes its tree readable.
A readable row a device holding the keys writes carries the account's
signature in place of a nonce; one nobody signed is locked wherever
privacy holds (in private trees, for items once sealed, and for tags and
settings once sealed), so the instance can't plant content, confirm a
guessed tag name by folding, or switch Count me on. Signed readable rows
from a device that hadn't heard are shown and sealed anew.
Rewrites are kept as under way until done, write the top studyset last,
hold writes into their tree meanwhile, and are finished as the data opens
and after each merge; a sweep seals what a stale device wrote. Cards moved
into a private tree are sealed with their files first; cards of a private
tree are never moved into a readable one. A private topic moved out of
its tree keeps its privacy with a key of its own. Settings carry a counter
inside their sealed payload, and a lower one than seen is refused. The
person's readable tags and settings are sealed once. A device without the
keys of an account that has them writes nothing that would be private and
reports nothing for the learner count; an anonymous person's keys kept but
unreadable are never replaced. Publishing sends only the names of the
tags the release carries. Key refusals are remembered per row.
Tests on the real gateways over a local database: a replayed readable top
row, a withheld key record, a moved parent, a planted revision, a signed
stale revision, card and topic moves, an interrupted rewrite, a settings
rollback, a device without keys, planted tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
The developer's decision of 21 Oct, on the library's cards and the
studyset's Share in its action row alike:
- a lock: private, end-to-end encrypted;
- an open lock: readable by the instance, not published;
- a globe: published.
The lock and the open lock open one dialog saying what that means and
changing it (Make non-private; Make private, or Publish…, which opens the
Publish sheet, while it isn't private); a topic's dialog says its privacy
is its tree's. The globe opens the Publish sheet, whose status for a
studyset not published now says so ("Not published") with an open lock,
as "Private" means something else now. Texts in en and de.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
- `AssetRepository` seals a file of a private studyset with its tree's key
for its asset row before hashing it (`AssetSealing`, which `Privacy`
does): the row's hash and size and the file uploaded are the sealed
file's, the upload says it is encrypted, and the row's payload, sealed
too, carries the file name and the hash of what was sealed (asset
schema 2). A download is checked against the row's hash, opened for its
row and checked against what was sealed. This device keeps the sealed
file to upload and the opened one, by its own hash, for what reads files
by path.
- The same file is a row per key: in a private and a readable studyset, or
two private trees, it is two.
- Every place adding a file names its studyset: the editors' pictures and
sounds (`StudysetFiles`, which every screen of a studyset and the PDF
reader set), PDFs and screenshots, Anki imports, copies and their updates
(a copy's top studyset is made first, so its files take its key), and
accepted suggestions (by their item's tree).
- Making a tree private or not, or moving a topic into another tree, moves
its files: each under another key is added anew under the tree's, the
content names the new row (whole `asset:` values and a PDF's file, never
text), and the old row is blanked and deleted once nothing else names it.
- Tests: a private studyset's file never uploads readable; a sealed file
moved to another row, or under a key not open here, doesn't open; a
changed file is refused by its hash; one row per key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
New studysets are private; those from before stay readable until the
person chooses. The library offers once, on this device, where it can
encrypt and some top-level studyset isn't private: Make all private, or
Not now; either answer is remembered (`private_offer` in the person's
folder) and the offer goes. Each studyset's menu changes it later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
- A top-level studyset's options make it private, with everything inside
it, or non-private again, after a dialog saying what changes and that
the instance's backups keep readable copies until they expire; a device
without the account's keys says how to get them there.
- A locked studyset (its key doesn't open here) is titled Locked, says
why when opened, and offers only Delete.
- Publishing names the person's tags as the device reads them, and says
what to do when the studyset is private or a tag has no name.
- The export sheet says an archive is readable by whoever has the file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Count me stays in the person's settings, which follow them to every device
and are now encrypted; after each sync the device mirrors it to the
instance (`learningCount.setCounted`) and, only while it is on, reports
which publications their copies come from, topics with them
(`reportCopies`). Taken back, the instance forgets the copies. Only people
with an identity report, as only they are counted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
- `ContentKeys` opens the person's data keys (`content_key`) with their
account's keys on the device, an anonymous person's device keys made as
their data first opens; it makes a private studyset's key and the
personal key. Without the account's keys nothing opens and nothing new
is private (D8).
- `PayloadEnvelope` is the one place payloads are sealed and opened, each
for its table, row and column, so a payload moved to another row never
opens; one that doesn't open is `Locked`, never half-read.
- `Privacy` gives each gateway the key of the row's tree (its top-level
studyset's), and rewrites a tree under another key, or readable, in
place (`rekeyTree`).
- The studyset, card, note, learning path, path answer and PDF gateways
seal with their tree's key and open what they read: a new top-level
studyset is private where the device holds the keys, a new one inside a
tree takes its privacy, and one moved into another tree is rewritten
under that tree's key. Tags and settings are sealed with the personal
key; a tag or settings row that doesn't open here is left out, so
nothing folds or deletes it. A studyset whose key doesn't open shows as
locked (`Studyset.isLocked`); other rows that don't open read as blank.
- Every payload codec round-trips sealed, and refuses another row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
- `content_key`, a synced table in the person's space, keeps their data keys
sealed to their account (a private studyset's, their personal one);
`learning_path_answer` gains `keyId` and `nonce` for answers in private
studysets. Additive only, on the server and the device.
- Publishing refuses a private studyset, and a tree holding a private row
(`PublishRefusal.private`); a private studyset elsewhere no longer breaks
publishing a readable one. Encrypted tags are named as the device names
them (`tagNames`): a name for a tag that isn't the person's, or an
encrypted tag of the release without one, refuses it
(`tagNamesMismatch`); a readable tag keeps its own name.
- The connector leaves private studysets out and says how many there are.
- The learner count reads no payload any more: Count me is kept by the
instance (`counted_learner`) and the device reports which publications
its copies come from (`learning_copy`, accepted only while counted, each
report replacing the last); only the reporter's own studysets count.
Deleting a person deletes both.
- An upload says whether its file is encrypted: only a readable one over
20 MB is checked to be a PDF, and an encrypted one may be 4 KiB larger.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Someone identified was offered Add GitHub wherever GitHub signed people in,
and on an instance offering passwords the browser round trip ended in
"Couldn't sign in with GitHub.", as the instance adds GitHub to nobody there
(ADR 0017, D2; the end-to-end test of 21 Oct). The profile now shows the
GitHub row there only to whom it signs in already, and an anonymous
person's Sign in with GitHub, which still reaches the account someone has,
says why it didn't: a GitHub account nobody has, with the password beneath
in its place, or one that signs in with its password now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
Where an instance offers password sign-in, GitHub is added to nobody (ADR
0017, D2), yet the start screen still said "then add GitHub" after a GitHub
account nobody has, and every refusal but an unknown account read
"Couldn't sign in with GitHub." (the end-to-end test of 21 Oct).
- The gateway tells from one read of the basics whether GitHub signs in and
whether it is added to people (`gitHubOffer`, in place of
`gitHubClientId`); the GitHub bloc keeps it (`Ready.linking`) for the
profile, with why an attempt failed (`Ready.problem`).
- The instance's `usePassword`, `deprecated` and `ended` become problems of
their own (`gitHubSignInProblemOf`), worded by `gitHubProblemText` and on
the start screen; an anonymous person refused a link is told the GitHub
account is nobody's and offered the password instead.
- Where passwords are offered, the start screen offers an account to create
in place of the app's hint on adding GitHub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>