docs(adr): mobile data, one file store, shared spaces

The developer's decisions of 10 Nov 2026 on their evening's requests:

- ADR 0042: every app holds its sync back on a metered connection, as the
  OS says, and asks in a quiet banner (Sync now, Without files, Not now);
  an answer holds until the connection changes; Settings › Sync chooses
  Ask, Without files or Everything.
- ADR 0043: Learn's file store moves into halkyon_base, room counted per
  service; Todo gets sealed attachments in descriptions and comments, any
  file up to 100 MB, fetched when shown.
- ADR 0044: shared spaces in halkyon_base for every app, Learn's studysets
  first: Owner, Editor and Viewer, keys wrapped to members' key pairs,
  invites by handle or email, links off at first, the material to publish
  chosen item by item, and agents as accounts of their own whose changes
  are suggestions a person accepts. Its open questions come before
  building, first how rows move between spaces.

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

View file

@ -0,0 +1,55 @@
# ADR 0042: Sync held back on mobile data
- **Status:** decided 10 Nov 2026 by the developer: D1 is their own request, D2 to D5
the agent's recommendations, which they chose. Not built yet.
- **Builds on:** ADR 0014 (halkyon_app, every app's shell, with its `SyncService`).
## Context
Every Halkyon app syncs whenever something changes and on a timer, on whatever network
the device has. Learn's library holds tens of thousands of rows and hundreds of
megabytes of PDFs; a first sync, or an import on another device, can take a phone's
monthly data on the go. The developer asked (10 Nov 2026): *"It should hold back
everything, asking the user whether one wants to sync. Then, give also the option to
exclude files, images etc."*
## Decisions
- **D1.** On mobile data, every app holds back its sync, rows and files alike, and asks
first. Built once, in halkyon_app, around its `SyncService`; nothing an app does on
the device waits on it (local-first as before: what is written is kept and sent
later).
- **D2.** *Mobile data* is a **metered** connection as the operating system says: on
Android its `NET_CAPABILITY_NOT_METERED` missing, which takes in cellular and also a
phone's hotspot or a Wi-Fi network marked metered; read by a few lines of Kotlin in
halkyon_app's plugin code (`connectivity_plus` tells only the kind of network, which
misses the hotspot). iOS: `NWPath.isExpensive`, once the iOS builds ship. Desktops
are never metered.
- **D3.** It asks with a **quiet banner** above the lists, never a dialog: *On mobile
data · 12 changes waiting* with *Sync now*, *Without files* and *Not now*. *Not now*
hides it until something new waits. Pulling a list down to sync is an explicit ask
too, and syncs as *Sync now* would.
- **D4.** An answer **holds until the connection changes**: while the device stays on
this metered network; asked again after Wi-Fi came and went, or when the app starts
again on mobile data.
- **D5.** Settings › Sync has *On mobile data*: **Ask** (the default), **Without files**,
**Everything**. No *Never*: *Not now* holds back already. A device setting (as the
look), not synced: a tablet on Wi-Fi alone and a phone differ.
## What "without files" means
Rows sync; files neither go up nor come down. Each app names its files to halkyon_app
as a queue of its own (Learn's asset uploads and downloads, its PDFs and Jellyfin
covers; Todo's attachments, ADR 0043; Train's maps' tiles stay as they are, fetched
when shown), which `SyncService` pauses and resumes. What is waiting shows in Sync's
card (*3 files waiting for Wi-Fi*).
## To work out when building
- Where the banner sits in each app's frame: halkyon_ui's `NavigationFrame` has no
place for one above every tab yet.
- The sync engine's stream: whether held back means not opening it at all (the
recommendation: nothing goes over the network, not even the handshake) or opening it
and sending nothing.
- Learn's offline reads (b578fcb9) must not wait on a sync held back: the tests of
`ContentKeys` and the gateways already run without one.

View file

@ -0,0 +1,58 @@
# ADR 0043: One file store in halkyon_base; Todo's attachments
- **Status:** decided 10 Nov 2026 by the developer: D1 and D2 are their own requests,
D3 their choice over the agent's recommendation (20 MB), D4 and D5 the agent's
recommendations, which they chose. Not built yet.
- **Builds on:** ADR 0004 (Learn's assets, rule 5), ADR 0014 (the module), ADR 0017
(the account's keys), ADR 0019 (sealed files), ADR 0033 (Todo), ADR 0042 (mobile data).
## Context
Learn keeps people's files (images, sounds, PDFs) in the server's private storage by
space and SHA-256 (`AssetEndpoint`, `file_limits.dart`, `file_pruning.dart`), counts
each person's room against `FILE_LIMIT_IDENTIFIED_MB` and the operator's allowances,
and seals a private studyset's files on the device (`HLKF`, ADR 0019). Todo has
descriptions and comments as rich text (ADR 0033, D36, D38) and no files. The developer
asked for attachments in both, now (10 Nov 2026).
## Decisions
- **D1.** Todo gets attachments in a task's description and in comments: an image shows
inline, any other file as a chip with its name and size that opens in the system's
app (or saves, on a desktop).
- **D2.** The file store moves into **halkyon_base**: the endpoint (upload by hash into
the caller's own space, finish, usage, download by a row a member may read), the
limits, the room counted and the pruning. Learn's server uses the module's and drops
its own; the tables keep their names and data (a move with the version rows alone,
as ADR 0014's moves). The module asks each app, through a hook as `deletePersonData`,
which synced rows name a file, for download rights and the pruning: Learn's `asset`
rows, Todo's attachment rows.
- **D3.** Todo takes **any file up to 100 MB**, as Learn's PDFs. Every Todo file is
sealed (everything of Todo's is, ADR 0033, D5), so the server can't look into it and
checks only its size and the sealed header; Learn's rule for readable files (over 20
MB only a PDF) stays Learn's.
- **D4.** **Room per service**, Learn's rule: each server counts its own people's files
against its own `FILE_LIMIT_IDENTIFIED_MB` (500 MB by default), `FILE_LIMIT_INSTANCE_MB`
and allowances. The operator's grants screen (ADR 0021) grants a service's room on
that service.
- **D5.** A device fetches a file **when it is shown**, then keeps it in the person's
folder; offline, it has what was seen. On mobile data, *Without files* (ADR 0042)
holds back uploads and downloads alike; a file not fetched shows as its chip with
*Download*.
## Todo's rows
An `attachment` table, synced and sealed as every Todo table: the file's hash, sealed
size and kind (image or file), its name and the task or comment it belongs to, all in
the sealed payload but the hash, which the server needs to count and serve. The rich
text names the attachment by its row ID, as Learn's editor names an asset. Deleting a
task or a comment blanks its attachment rows (the payload first, ADR 0002, rule 2); the
module's pruning removes a file no row names any more.
## To work out when building
- The move of Learn's tables into the module: their migration SQL (version rows only,
or a rename where the module's naming differs), explained before it is applied.
- Whether the sealed header (`HLKF`) and Learn's `SealedFile` move to `halkyon_keys`
for Todo to share, which the recommendation is.
- The quota's text in Todo's Settings: Learn shows the room used in its Sync card.

View file

@ -0,0 +1,82 @@
# ADR 0044: Shared spaces and collaboration, people and agents alike
- **Status:** decided 10 Nov 2026 by the developer: D8 is their own request, D9 and D10
their choices over the agent's recommendations (all ticked at first; agents editing
directly), D1 to D7 the agent's recommendations, which they chose. Not built yet; the
open questions below come first.
- **Builds on:** ADR 0005 (publishing, reports and suggestions), ADR 0014 (the module),
ADR 0016 (one account across an instance), ADR 0017 (key pairs), ADR 0019 (private
studysets), ADR 0021 (operators and assistants).
## Context
A studyset belongs to one person: their rows are in their personal space, a private
one sealed with a data key that only their own devices unwrap. The developer asked (10
Nov 2026) for Vaia's sharing (*Manage collaborators*: invite by email, an invite link,
the members and their roles; *Collaboration settings*: joining by link, collaborators
inviting others; *Share with the community*: choosing what material goes public) and
for collaboration with everyone, people and agents alike: *invite @Paul or @Claude to a
studyset*, Claude Code on halkyon.li's host working as an agent account, as Copilot or
Claude work on a GitHub repository.
The sync engine already has shared spaces (`OfflineSyncSpaces`: create, grant, revoke,
members), with two roles, `readOnly` and `readWrite`, which limit writes alone: a
member reads everything in the space.
## Decisions
- **D1.** **Shared spaces live in halkyon_base,** for every app: members, roles,
invitations, links, settings and the wrapped keys. Learn's studysets use them first,
Todo's projects later on the same tables.
- **D2.** **Roles: Owner, Editor, Viewer.** The Owner can do everything, delete the
studyset and hand it to another member; an Editor changes content; a Viewer reads and
studies, with their own reviews in their own space. Editor and Owner are the engine's
`readWrite`, a Viewer its `readOnly`; who is Owner is the module's own table, checked
by its endpoints.
- **D3.** **A private studyset's key is wrapped to each member's public key** (ADR
0017's key pairs) by a member's device that holds it, at its next sync after someone
joined. The instance never sees the key; reading waits for that sync. A member
removed gets no new content: the studyset rolls to a new key, as ADR 0019's rewrites.
- **D4.** **Invites by @handle or by email.** An @handle adds a person on the instance at
once (they see it in their Inbox and may leave). An email address gets a mail with a
link to join, which works after signing up; the address is never shown to the other
members.
- **D5.** **Joining by link is off at first;** someone joining by it is a Viewer, whom
the Owner can raise. *Collaborators may invite others* is off at first too.
- **D6.** **Agents are accounts of their own,** marked as agents, each owned by a person
who made it from their Profile; listed in the owner's Profile, deleted with them. An
agent has its own key pair, kept where it runs, signs in with a token the owner made
(never a password), and is invited like a person: *@claude*.
- **D7.** **An agent reaches the instance through a small CLI or MCP server** run beside
it (Claude Code on halkyon.li's host runs as its own user, without Docker): it signs
in with its token, syncs the spaces it is a member of, opens the sealed rows with its
own keys, and writes through the same endpoints an app uses.
- **D8.** **Share with the community** (publishing to Explore, ADR 0005) gets a step
confirming the material: every PDF, other file and note listed by kind.
- **D9.** That step's choice: **all unticked at first**, with per kind *tick all*, and
on any item *everything but this* and *nothing but this*. Cards and the learning path
always go.
- **D10.** **An agent suggests; a person accepts.** An agent is a Viewer in the engine's
sense and writes nothing into the studyset; its changes go as suggestions, which a
member with Editor's rights accepts or declines, each marked as by @claude. Learn's
reports and suggestions (ADR 0005, stage 4: `feedback_thread`, the author's
decisions, accepted suggestions applied by their item) are the start: they become
suggestions within a shared space too, not only on published releases.
## Open questions, before building
- **Moving rows between spaces.** A studyset's rows are in its owner's personal space;
sharing moves them into a new shared space. Row IDs are unique across a table and the
engine keeps a deleted row's tombstone until its space goes, so a row can't simply be
deleted in one space and inserted in another under the same ID. Either the engine
learns to move a row between spaces (a change to our fork), or sharing copies the tree
under new IDs and rewrites what names them (cards' reviews, tags, the learning path),
as ADR 0019's rewrites do with keys. To try against the fork first.
- **Whose reviews.** A Viewer's or Editor's schedule for a shared card stays in their own
space, keyed by the card's ID; the card's own review rows today assume one learner.
- **Handing on and leaving.** An Owner leaving must hand the studyset on first, or it
goes with them, as their account's deletion deletes their spaces.
- **Members across services.** On an instance with a register (ADR 0016), handles are
the register's; a member of a Learn studyset needs no Todo account and the reverse.
- **The agent's token:** made and revoked in the owner's Profile, scoped to the agent's
account, never shown again after making; whether it expires.