Commit graph halkyon/docs/adr/0044-shared-spaces-and-collaboration.md
Author SHA1 Message Date
fb6b78fa19
feat(app,learn): invite to a shared studyset by email or link, and join
The apps' half of ADR 0044's step 5 (D37 to D40), on the instance's
half of the commit before.

halkyon_app's SharedSpaceGateway asks for inviting by mail, the
preview of a link, the code proving an address, accepting a mailed
invitation, a space's link (read, switched, joined) and marking a
share seen; SpaceInfo says whether a share is new and who made it,
a member whether they were invited by mail. joinTokenOf reads a link
or its token as pasted. The fake keeps the mails and codes it would
send, for the apps' tests.

In Learn:
- the Owner's Share sheet invites by handle or by email, lists an
  invitation mailed by its role alone, and switches the space's link,
  copying it while on;
- the Library's menu has Join with a link, and a link opened on
  Android (/join/<token> on learn.halkyon.eu, verified through the
  instance's assetlinks) opens the same on a screen of its own: who
  invites and as whom, then Join, or for a mailed invitation the
  address and the code mailed there;
- a studyset someone shared is new until opened: in the Inbox
  ("@alice shared Japanese with you", Open or Leave), on the Profile
  tab's badge and as New on its library card (NewSharesCubit, read
  again after every merge).

Whats-New: Invite people to a shared studyset by email or with a link, join one from the Library's Join with a link, and see in the Inbox what was shared with you.
Whats-New-De: Lade Leute per E-Mail oder Link zu einem geteilten Lernset ein, tritt einem über „Mit einem Link beitreten“ in der Bibliothek bei und sieh im Posteingang, was mit dir geteilt wurde.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 19:11:06 +01:00
554e1604b4
feat(base,app,learn): invitations to shared spaces by mail and by link
The instance's half of Halkyon's ADR 0044, step 5 (D37 to D40).

An invitation by mail (sharedSpace.inviteByEmail) goes out through the
register (send-space-invitation), naming who invites by their handle
and nothing of what is shared, with a link to /join/<token>. The
instance keeps only the address's keyed hash, as for sign-in, and the
SHA-256 of the token. Whoever opens it joins with a code mailed to that
address (EmailCodePurpose.joinSpace, proven through the register,
prove-address-code), so a forwarded mail admits nobody else; a code
proving another address is refused. It lapses after 14 days, and the
hourly pruning forgets it; inviting the address again replaces it.

A space's own link (setJoinLink, the Owner's) admits whoever opens it
as a Viewer (joinByLink) while it is on; switched off and on, it gets a
new token. Its token is kept as it is, so that whoever may invite copies
the link again (joinLink). The page at /join/<token>, in English and
German, says who invites and as whom; preview tells the app the same.

A membership someone else made is new to its member until they mark it
seen (isNew, addedBy; mine names who shared it), for the Inbox.

halkyon_app learns the two new refusals (handleNeeded, otherAddress),
which Learn reports as not allowed until its screens offer these ways
in. The ADR records D37 to D40 and how the instance's half is built.

The migration, the same in the module and in each server including it
(Learn, Meditate, Todo, Train):

  ALTER TABLE "shared_space" ADD COLUMN "joinToken" text;
  CREATE UNIQUE INDEX "shared_space_join_token_idx"
    ON "shared_space" ("joinToken");
  CREATE TABLE "shared_space_email_invitation" (…), with its unique
    indexes on the space and address hash, and on the token's hash,
    and its foreign key to "shared_space" ON DELETE CASCADE;
  ALTER TABLE "shared_space_member" ADD COLUMN "addedBy" uuid;
  ALTER TABLE "shared_space_member"
    ADD COLUMN "isNew" boolean NOT NULL DEFAULT false;

Every space gets no token, and the unique index allows any number of
nulls; every membership gets no one who added it and isn't new, so
nothing already shared shows in an Inbox. The new table starts empty.
None of it rewrites a table, and the server before the switch names its
own columns, so it keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 18:36:21 +01:00
8dd3ec0522
feat(learn): a Viewer opens a shared studyset's material read-only
Every route of a studyset says what the person may do there
(StudysetAccess, set in _inStudyset): whether it is shared with people,
and whether they only view and study it (Halkyon's ADR 0044, D35, D36).

A Viewer opens cards and notes on the same screens, read-only: the rich
fields show their content (LearnContentEditor shows LearnContentView),
the composer's plain fields are read-only and its changes go nowhere,
the note screen reads as a conflict leaves it, each with the line that
the studyset is theirs to view and study. The lists, the card browser
and a study session offer them nothing that writes: no new card, import,
edit, move, rename, reading deadline, reorder or delete. Their tags,
progress and reading stay theirs to change, being their own rows.

Burying and reporting a card are written on its row, which every member
shares, so they are hidden in shared studysets for everyone until they
become each person's own rows (D36).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 17:49:49 +01:00
7f1f07883a
feat(base,app,learn): a shared space's key rolls once a member goes
What is written in a shared space after a member went takes a key they
never get (Halkyon's ADR 0044, D22). The instance marks the space as one
of its members goes, removed, leaving or deleted (`keyRollDue` on
`shared_space`, set in `removeMember`, through which each of them
passes); an invitation taken back never had a key and marks nothing, nor
does a new role. The Owner's or an Editor's device that next keeps its
keys up after a sync is told so (`sharedSpaceKeys.rollDue`, never true
for a Viewer, who seals no key) and makes a new key, sealed to those who
stay (D33). The first seal bringing a key ID the space never had clears
the mark in the same transaction (D34); members the rolling device
missed get it later as newcomers do. Earlier rows keep the key they had,
which whoever went had read already.

The migration, the same in the module and in each server including it
(Learn, Meditate, Todo, Train):

  ALTER TABLE "shared_space"
    ADD COLUMN "keyRollDue" boolean NOT NULL DEFAULT false;

It adds the column with false for every space there is, so no space is
due a roll from it; PostgreSQL adds a column with a constant default
without rewriting the table. The server before the switch reads and
writes the table naming its own columns, so it keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 16:57:49 +01:00
4b900aa930
docs(adr): shared spaces, their screens in step 4
ADR 0044, D29 to D32: Share opens one sheet, people first, then the
community; a Viewer's studyset screen is read-only; a shared studyset
is marked with people; safety codes are compared from a member's row,
members matched to their keys by handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 15:53:03 +01:00
f0b05a5e9a
docs(adr): shared spaces, a published studyset and the copy
ADR 0044, D27 and D28.

- D27: sharing a studyset with a publication anywhere in its tree is
  refused for now. Publishing reads a release from the person's
  readable rows on the instance, and a shared studyset is sealed in
  another space, so it could take no release; a later step has the
  device hand over a release's content and re-points the publication.
- D28 (the agent's, to be confirmed): the copy is the archive's export
  and import, so progress, tags, stars and the archive follow, each
  item keeping its link to Explore and each card its origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 14:13:38 +01:00
6b1e9a0226
docs(adr): shared spaces, decided further for step 4
ADR 0044's D20 to D26, the developer's choices on 11 Nov 2026, each the
agent's recommendation: a shared space's rows trusted when any member
pinned signed them (D20); a shared space always sealed (D21); rolling
its key seals new writes alone (D22); a shared studyset at the top level
for every member (D23); writes reaching a shared space through the sync
engine, our fork routing them (D24); tags staying each member's own
(D25); and personal rows naming shared content by a plain ID, as the
engine links no row of one space to another's (D26). Step 3 is marked
built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 12:40:17 +01:00
b72cea1fe0
feat(base,app,keys): keys of shared spaces, sealed to each member
Step 3 of ADR 0044 (D3, D18, D19). A shared space's data keys are
sealed on a device to each member's public keys; the instance keeps
what devices sealed and never opens it.

- halkyon_keys: the safety code of two accounts' public keys
  (`safetyCode`), 60 digits, the same on both sides.
- halkyon_base: the register hands out accounts' public keys
  (`public-keys-of`, local and remote); the table `shared_space_key`
  and the endpoint `sharedSpaceKeys` (memberKeys, keysNeeded, seal,
  mine). Only the Owner and Editors seal, the first key sealed to a
  member stays, and a member's keys go with their membership. Every
  host's migration creates the table.
- halkyon_app: `ContentCipher` seals a key to another account and
  opens one sealed by another; `SharedKeys` shares a space's key with
  its members, seals keys to newcomers and opens the person's own,
  holding each member to the public keys `KeyPins` pinned on first
  sight (`key_pins.json` in the person's folder), a change refused
  until trusted anew.

Learn takes them into its `Privacy` in step 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 11:35:42 +01:00
d08d8ad545
feat(base,learn): spaces people share, kept by halkyon_base
ADR 0044, step 2 (D1, D2, D11, D13, and D15 to D17, decided today).
halkyon_base keeps the spaces several people share, for every app:
shared_space, its members with their roles (Owner, Editor, Viewer), and
invitations by handle waiting for someone with no account on the
service yet. The sharedSpace endpoint creates a space, lists its
members, invites by handle, changes roles, removes members, hands the
space on, leaves and deletes it.

Who may do what:
- Only people with an identity reach the endpoint.
- A space the caller isn't in can't be told from one that doesn't
  exist.
- Members are named by their membership, never by their account's ID.
- Only the Owner manages the members, unless the space lets members
  invite.
- The Owner's role passes only by handing the space on, and the Owner
  leaves only naming who takes it on.
- An invitation counts as an answer about a handle, as it tells whether
  one exists.
Every rule has a test asserting the refusal.

The module stays free of the sync engine: a host hands it a SpaceGrants,
and every call runs in the module's transaction, so the engine's grants
follow the roles or nothing changes. The Owner and an Editor write; a
Viewer reads. Learn hands over SyncSpaceGrants on OfflineSyncSpaces. Todo
hands over none yet, so its server refuses until its projects are
shared.

Deleting an account hands each space the person owns to the Editor who
joined first, else the Viewer who joined first. A space with no other
member goes. This runs before the host's own deletions. A person's first
sign-in on a service makes their waiting invitations memberships, in the
same transaction that makes them a person there.

Migrations: the module's and every host's (Learn, Todo, Train, Meditate)
create the three tables, their indexes and the two cascading foreign
keys to shared_space, and nothing else. The old server keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 10:54:19 +01:00
f745516f36
docs(adr): shared spaces, decided further, in six steps
ADR 0044 records the answers to its open questions: a shared studyset
passes on when its owner leaves or deletes their account (D11); an
agent's token lapses after 90 days unused (D12); an invitation by handle
waits for someone who never used the app (D13); and sharing copies the
studyset under new IDs into the shared space (D14), since the sync
engine's delete is soft and a row's ID can't be inserted into another
space. Its Steps section orders the work, starting with the community's
confirmation (D8, D9).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 10:06:20 +01:00
5d47efd7c5
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>
2026-11-10 23:07:58 +01:00