halkyon/docs/adr/0044-shared-spaces-and-collaboration.md

176 lines
12 KiB
Markdown
Raw Normal View History

# 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; D11 to D13 the
agent's recommendations, which they chose on 11 Nov 2026, D14 too, and D15 to D26.
Steps 1 to 3 built; the rest below.
- **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.
- **D11.** **A shared studyset passes on.** Leaving, the Owner picks the next Owner
first. Deleting their account, ownership passes by itself to the Editor who joined
first, else to the Viewer who joined first; only with no other member does the
studyset go with them.
- **D12.** **An agent's token lapses after 90 days unused;** while used it has no fixed
end, the owner revokes it any time, and their Profile shows when it was last used.
- **D13.** **An invitation by @handle waits** for someone who has an account on the
instance's register but has never used the app: they find it in their Inbox the first
time they sign in to it, and the inviter sees them as invited until then.
- **D14.** **Sharing copies the studyset under new IDs** into the shared space, rewrites
what names its rows (reviews, tags, the learning path, files) and deletes the old ones,
as ADR 0019's rewrites do; the fork stays as it is. Found 11 Nov 2026: the engine's
delete is soft (`insteadOfDelete`: the domain row stays, hidden by a tombstone in its
space's `crdt_data_rows`), and a synced table's primary key is the row's ID alone, so
the same ID can't be inserted into another space; teaching the engine to move a row
was the other way, set aside as a deeper change to its merge, kept on every rebase.
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:33:12 +01:00
- **D15.** **The module reaches the sync engine through its host** (decided 11 Nov 2026,
the agent's recommendation): each host hands halkyon_base a `SpaceGrants` (create a
space, grant, revoke, delete), made with `OfflineSyncSpaces`, as it hands a
`FileStore` (ADR 0043), so the module stays free of the engine and its tests run on a
stand-in.
- **D16.** **A shared space's tables are its service's own** (decided 11 Nov 2026, the
agent's recommendation): a Learn studyset's space and members are in Learn's
database, beside the engine's space they mirror, a Todo project's in Todo's; members
are the register's account IDs, their handles asked of the register as now.
- **D17.** **Never used the app means no account on its service yet** (decided 11 Nov
2026, the agent's recommendation, reading D13): someone without an `AuthUser` row on
the service gets a waiting invitation, made a membership the first time they sign in
there; on the register, where everyone has one, the invited are members at once.
- **D18.** **A member's public keys are pinned, a change refused, a safety code
compared** (decided 11 Nov 2026, the agent's recommendation): only the instance hands
a device a member's keys, and one that swapped in its own would read every studyset
shared with it. So each device keeps the keys it first saw for each person (as Signal
and SSH do), seals nothing to keys that changed and says so, and shows a safety code
made of both people's keys, which two members compare in person or by call; a swap
after the first sight is caught, one before it by comparing.
- **D19.** **Step 3 builds the module's wrapped keys and halkyon_app's device side**
(decided 11 Nov 2026, the agent's recommendation), each tested; Learn takes them into
its `Privacy` with the copy in step 4.
- **D20.** **A row in a shared space is trusted when any member this device pinned
signed it** (decided 11 Nov 2026, the agent's recommendation): current or former,
never by the author column a row names, which the instance could alter; a few
signature checks a row, for a group of a few people.
- **D21.** **A shared space is always sealed** with its key (decided 11 Nov 2026, the
agent's recommendation): sharing needs keys, so someone without a password sets one
first, and what goes public goes through *Share with the community*. ADR 0019's
readable records stay out of shared spaces.
- **D22.** **Rolling the key seals new writes alone** (decided 11 Nov 2026, the agent's
recommendation, reading D3): new and changed rows take the new key, earlier ones stay
under the key they had, which the members keep, since whoever went had read them
already. The instance marks a space whose member went, and the next writer's device
makes the new key and seals it to the members.
- **D23.** **A shared studyset is at the top level for every member** (decided 11 Nov
2026, the agent's recommendation): sharing takes it with its subtree out of its
parent, marked as shared; placing it in one's own tree may come later. Invited by
@handle, it appears in the invitee's list, which they may leave; the Inbox is step 5.
- **D24.** **Writes reach a shared space through the engine** (decided 11 Nov 2026, the
agent's recommendation): found 11 Nov 2026, every gateway writes in the personal space
and an update of a shared row finds none. Our sync fork updates and deletes a row in
its own space, checked against the person's role there, and asks the host which space
a new row goes in, which Learn answers in one place (a card by its studyset, a
revision by its card), so no gateway changes and none is missed; wrapping each write
in `transactionForUser` was the other way.
- **D25.** **Tags stay each member's own** (decided 11 Nov 2026, the agent's
recommendation): in their personal space under their personal key, as reviews (D2).
- **D26.** **Personal rows name shared content by a plain ID** (decided 11 Nov 2026, the
agent's recommendation): found 11 Nov 2026 by experiment, the engine blanks a
reference from one space to another (its rule, *foreign keys stay within a space*),
and reviews, tags (cards', notes', paths'), PDF readings and path answers are
personal (D2, D25) but name cards, notes, paths, steps and PDFs. Those six tables
keep their columns without the database relation; Learn deletes what names an item
as it deletes the item, and upkeep prunes rows naming one gone. Putting them in the
shared space would show members each other's reviews; teaching the engine to link
across spaces goes against its design.
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:33:12 +01:00
## Steps
1. **Share with the community's confirmation** (D8, D9), needing nothing else here.
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:33:12 +01:00
Built 11 Nov 2026.
2. **Shared spaces in halkyon_base** (D1, D2, D11, D13): the module's tables (a shared
space, its members and roles, its Owner), its endpoints with their denial tests,
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:33:12 +01:00
the engine's grants kept in step, the hand-on as an account is deleted. Built 11 Nov
2026: the module's `sharedSpace` endpoint, Learn's `SyncSpaceGrants`; Todo hands over
no engine yet, so its server refuses (`unavailable`) until its projects are shared.
3. **Keys** (D3, D18, D19): a private studyset's key wrapped to each member's key pair.
Built 11 Nov 2026: the register hands out members' public keys (`public-keys-of`),
the module keeps the keys devices sealed (`shared_space_key`, its `sharedSpaceKeys`
endpoint, a Viewer sealing none, a member's keys going with their membership);
halkyon_app seals and opens them (`SharedKeys`), pinning each member's public keys
on first sight (`KeyPins`), with the safety code of halkyon_keys (`safetyCode`).
4. **Learn shares a studyset** (D14, D20 to D26): the copy, *Manage collaborators* (invite by
@handle, the members, their roles, leaving), each member's own reviews.
5. **Invitations by email and by link** (D4, D5), the Inbox.
6. **Agents** (D6, D7, D10, D12).
## Open questions, before building
- **Moving rows between spaces.** Decided: D14.
- **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.** Decided: D11. To build: the account's deletion hands the
shared spaces it owns on before it deletes the person's own.
- **Members across services.** Decided: D13. 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; its lapse decided: D12.