halkyon/docs/adr/0005-public-tier.md
Tizian 「ティツィアーン」 5641285d31
feat(learn): Explore by what a licence allows, a skeleton while the library loads, and an assistant's agreement per provider (ADR 0005, rule 12; ADR 0021, D66)
- Explore filters by what a studyset's licence lets a learner do: adapt
  it (no NoDerivatives) and use it commercially (no NonCommercial),
  which the instance reads from the release's licence
  (ExploreQuery.adaptable, commercial) and the app the same way
  (LicenseUse). An instance from before ignores both.
- While the studysets are read, the Library shows shimmering skeletons
  of their cards rather than a spinner: halkyon_ui's new Shimmer and
  SkeletonBox, still where the system asks for no animations.
- The scope's "Include private studysets" is offered only while the
  person has a private studyset.
- An agreement to what goes where is per provider (protocol and host):
  agreeing to one assistant agrees to every assistant of that provider,
  those granted later too, and the agreement sheet says so. No
  migration: it is written on every grant of the provider, so taking
  one back keeps it for the others, and read per provider from any of
  them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-02 05:19:18 +01:00

158 lines
10 KiB
Markdown

# ADR 0005: The public tier: releases in git, a copy that follows, federation
- **Status:** accepted
- **Date:** 2026-10-09
## Context
The Japanese studyset is meant to be the project's public example: other people, on this
instance and on others, should be able to find it, learn from it, and help improve it.
Until now content has two tiers (ADR 0001, ADR 0002): private, a person's own synced space,
and shared, spaces granted to people of the same instance. Explore has stayed empty.
A direction for a third, public tier was recorded on 2026-09-27 (in the working notes, not
decided): releases kept in git, published as one-way snapshots, announced over ActivityPub
the way PkgFed proposes for package releases, with an index on halky.one, learners'
instances mirroring what they learn from, and reports and suggestions as issues and pull
requests behind a gateway. This ADR decides it, and the order it is built in.
## Decision
1. **The public tier is federated from the start, and built in usable stages.** Each
stage works on its own; none is built in a way the later ones would undo:
1. public identity (handles, profiles);
2. releases in git;
3. Explore, and learning from a release (rule 4);
4. reports and suggestions (rule 6);
5. releases across instances over HTTPS;
6. ActivityPub, through Fedify (rule 7).
2. **A person appears under a handle on the account domain, Mastodon's way:**
`raisondetredev@halkyon.eu`. The account domain is the instance operator's setting,
apart from the server's host (learn.halkyon.eu), as Mastodon's `LOCAL_DOMAIN` and
`WEB_DOMAIN`; the account domain then answers WebFinger by pointing to the host. It can
never change once anyone follows a handle on it. A handle can change until its person
first publishes, and is fixed from then on, as followers and links refer to it; moving
later goes the fediverse's way (`Move` to another account). A public profile holds a
display name, a short bio, links (shown as verified when the linked page links back
with `rel="me"`) and an avatar. Only people signed in with an identity provider (the
`identified` scope) can have one: anything involving other people needs it.
3. **A release is a commit and a tag in a git repository of its own per published
studyset**, a bare repository on a volume of the server, worked on through the `git`
command, never a library linked into the server. The server builds a release from the
publisher's synced rows (which it can read, ADR 0002): no device sends a snapshot. Its
layout is a directory per studyset of the tree and a file per card, note, path, step
and PDF, with their files, in a stable serialisation, so git's trees give every
subtree a hash of its own (a learner of one part can tell whether it changed) and
diffs mean something. Releases are numbered and immutable; a release can be withdrawn,
never rewritten. Only studysets under a licence that allows passing them on can be
published, as mirroring and copying are passing them on; the licence and its credits
travel with every release.
4. **Learning takes a copy that follows.** The learner gets the release as their own
studyset, editable and studied offline like any other; each card keeps a reference to
the published card (its studyset's identity and its own, both stable across releases)
and the release it came from. A later release is offered, never applied by itself, and
merged per side as the dictionary's corrections are (ADR 0003): what only the release
changed comes in, what the learner changed stays, a side both changed is a conflict
the learner settles. New cards are offered, withdrawn ones marked; progress is never
deleted by a release.
5. **Names are locations:** a published studyset is `halkyon.eu/@raisondetredev/japanese`;
its identity is its UUID, the location only finds it (a move is announced).
6. **Reports and suggestions are ours, on plain git, not a forge's.** A report (an issue)
is a thread on a published studyset or one of its cards; a suggestion (a pull request)
is an edit made in the app's own editors. Both are written as rows in the writer's own
synced space, so they work offline and travel with the sync engine; the server routes
them to the author's inbox. Accepting a suggestion commits it to the repository with
its writer as `Co-authored-by`, and the next release carries it. Across instances they
travel later as ForgeFed's tickets and offered patches, through Fedify.
7. **ActivityPub is spoken by Fedify, in a container of its own** beside the server
(TypeScript, MIT), behind Caddy's federation paths: WebFinger, actors, HTTP signatures
(both kinds), delivery queues, its state in PostgreSQL. The Serverpod server records
what to announce; Fedify announces it. A release is announced with its hash and where
to fetch it, and travels over HTTPS (stage 5), never inside an activity; no core
function depends on federation. halky.one may later index releases, never host them.
## Addendum: stage 3, Explore and learning from a release (2026-10-09)
8. **Explore finds by fit, never by popularity.** It lists what is published by search,
languages and kinds of content, the newest release first, a page at a time, the next
only when asked for. Votes (ActivityPub likes) cost nothing to fake, so they count for
nothing. Learning activity is harder to fake and is **shown, never ranked**: "10+ learn
this here", counting only people of *this* instance (a number another instance sends
could be anything), with an identity, who chose to be counted (opt-in), and who
studied a copy on 3 or more days of the last 30; shown rounded, from 5 on, so that
nobody can be told apart. Ranking by it would keep the first good studyset on top for
good.
9. **The app makes and updates a copy; the server never writes into a learner's
space.** A copy is written through the learner's own repositories, as an import; each
item names its origin (publication, item, release). A later release is offered on the
copy with what it brings and taken at a tap: per side for cards, notes and steps (as
rule 4 says), per field for studysets, paths and PDFs; withdrawn items stay, marked and
studied on; what the learner deleted stays deleted.
10. **A copy may be published within its licence:** never under "no derivatives", under a
ShareAlike licence only under that licence; it says what it is based on. An author can
take a studyset off Explore again: nobody can add it then, and copies get no updates;
its releases stay.
## Addendum: stage 4, reports and suggestions (2026-10-09)
11. **Reports and suggestions are public, gathered by the instance from each writer's own
space.** A report or suggestion on a published studyset (or one of its cards, notes or
steps), its replies and its author's decisions are rows of their writers' own synced
spaces, so writing works offline; the instance gathers them when they are read, and
decides then what counts: threads from people with an identity and a public profile the
author hasn't blocked, replies from the writer and the author alone, decisions from the
author alone, no suggestion under "no derivatives". Everyone signed in reads them, each
with its writer's public profile, but for those the author hides (then only writer and
author see them). The author replies, closes, hides, accepts or declines a suggestion,
and blocks people from writing on their studysets. A suggestion is a card, note or step
as its writer has it; accepted, it becomes the author's own edit, its files fetched from
the writer's, and the next release credits the writer (`Co-authored-by`).
## Addendum: Explore by what a licence allows (2026-11-01)
12. **Explore filters by what a licence lets a learner do, not by the licence itself**
(the developer's list; recommended, over a list of licences to tick, as Openverse
filters): *Adapt it* (no NoDerivatives) and *Use it commercially* (no
NonCommercial), each ticked or not, every studyset published already allowing it to
be copied and passed on (rule 3). Only Creative Commons' identifiers forbid either,
any version or port (`-ND-`, `-NC-`), which the instance reads from the release's
licence (`ExploreQuery.adaptable`, `commercial`) and the app the same way
(`LicenseUse`). An instance from before ignores both and lists everything.
## Consequences
- An instance's operator chooses an account domain once, and keeps WebFinger answering
on it for as long as the instance lives.
- Instances hold git repositories on a volume, which the backups must cover; the server's
image needs the `git` command.
- A learner's progress, notes and edits stay theirs whatever the author does: releases
only ever offer.
- Reports, suggestions, the author's inbox and the review of a suggestion as cards are
ours to build, in the app.
- A second runtime (Fedify on Node, Deno or Bun) joins the deployment at stage 6, with its
own container.
## Alternatives considered
- **Publishing and learning on one instance first, federation designed later:** reaches
learners sooner, but risks a format and identities other instances can't share.
Rejected for building in stages within the federated design instead.
- **A plain copy** (the old app's "Add to my library"): simplest, but an author's fix never
reaches anyone who already learns. **A read-only mirror with a personal layer of
reviews and overlays**: updates arrive by themselves, but cards can't be edited, only
overlaid, and much more has to be built.
- **The GitHub username as the public name:** would mean storing it, which the instance
avoids on purpose (only the numeric GitHub id is kept). **No author shown:** only the
licence holder would name anyone.
- **Releases as files by their hash, git later:** less to run, but history, diffs and
per-subtree hashes would have to be rebuilt by hand.
- **libgit2 linked into the server (git2dart):** no processes, but a native library in the
server and its build.
- **A Forgejo on each instance for issues and pull requests:** mature issues, reviews and
a web interface, but a second account system per instance (a Forgejo user per person,
usernames and emails stored, deletion to keep in step), an experimental federation that
would give a person a second identity in the fediverse, every action a live call (an
outbox of its own for offline use), and the review of a suggestion as cards still ours
to build in the app.
- **Our own ActivityPub in Dart:** one runtime, but every interoperability detail
(signatures above all) ours to get right and keep right.