| 1 | # ๐ค Space-wide apps: install once, use everywhere |
| 2 | |
| 3 | The problem this solves: an app you build (or love) shouldn't need adding to |
| 4 | every chat, by every person, before it's useful. If someone makes a sticker |
| 5 | library, "install it for everyone in the space" should mean exactly that โ its |
| 6 | slash commands work in any chat, its data is one shared library, and nobody |
| 7 | else installs anything. |
| 8 | |
| 9 | ## The design in one paragraph |
| 10 | |
| 11 | A space-wide install is the group-app model **one level up**. Every instance |
| 12 | gets (lazily, on the first install) a single hidden **space thread** โ a |
| 13 | `kind:"space"` thread with **implicit membership** (every member of the space, |
| 14 | by construction) whose AES key rides `open_key`, i.e. encrypted under the |
| 15 | **instance key** that every member already unwraps with their own private key |
| 16 | (the open-group mechanism). Which apps are installed is fold state from |
| 17 | `spaceapps.add` / `spaceapps.remove` events in that thread's encrypted log โ |
| 18 | the `groupapps` registry's twin โ and each installed app runs **one shared |
| 19 | instance** whose events and state snapshots live in the same thread. Commands, |
| 20 | @mentions, the sidebar's space band, scheduling, and snapshots all reuse the |
| 21 | existing per-thread machinery; only the thread is new. |
| 22 | |
| 23 | ## What the server learns (and doesn't) |
| 24 | |
| 25 | Nothing changes about rule one. The server stores: one thread row of kind |
| 26 | `space`, one wrapped key blob it cannot open, and opaque ciphertext. It never |
| 27 | learns **which** apps are installed (that's inside the log), what any app's |
| 28 | data is, or who uses what. The two server-visible facts are that a space |
| 29 | thread exists and its message timings/sizes โ the same metadata class as any |
| 30 | thread. |
| 31 | |
| 32 | - **No new key material class.** The user-facing question "does this mean a |
| 33 | second key per user?" has the answer *no*: members unwrap the instance key |
| 34 | with their personal key (existing path, late-joiner distribution and all), |
| 35 | and the instance key unwraps the space thread's key. The invariant "the |
| 36 | instance key never encrypts messages" holds โ it only *wraps* the space |
| 37 | thread's key, exactly as it wraps every open group's. |
| 38 | - **Implicit membership** (`db.threadMember` / `db.memberUserIDs`): every |
| 39 | non-agent member is a member of the space thread with no rows to backfill |
| 40 | and no join path that can drift. **Agents are excluded** โ a bot reading the |
| 41 | space log must be a deliberate, disclosed choice (an explicit membership row |
| 42 | can be added later), mirroring `joinDefaultThreads`. |
| 43 | - **Broadcast goes to everyone, and that's correct**: every member can decrypt |
| 44 | by design, so WS fan-out to all members leaks nothing. **Push is skipped** |
| 45 | for the space thread (`pushNewMessage` / `pushScheduled`): registry churn |
| 46 | and app traffic must not buzz every phone, and clients that don't know the |
| 47 | thread couldn't render the notification. Live WS delivery still applies, so |
| 48 | open clients stay current; a space app that wants notification-grade voice |
| 49 | is a future, deliberate design. |
| 50 | - **Invisible to old clients.** `threadsForUser` joins on membership rows, so |
| 51 | the space thread never appears in `GET /api/threads` โ web clients that |
| 52 | predate this, and the native apps until they learn the feature, never see |
| 53 | it. New clients fetch it explicitly (`GET /api/space-thread`). |
| 54 | - **The break-glass instance-key reset deletes it.** Unlike an open group, |
| 55 | members hold no per-member wraps for the space thread โ `open_key` under the |
| 56 | instance key is the *only* path to its key โ so after a reset its log is |
| 57 | unreadable forever. `resetInstanceKey` deletes the thread (journaled, inside |
| 58 | the same transaction so a racing create can't slip past), its messages, |
| 59 | state, schedules, and blobs; the next install starts fresh. One residual: a |
| 60 | tab left open **across** the reset still holds the dead instance key in |
| 61 | memory and could re-mint the space thread wrapped under it (the server |
| 62 | can't tell a live wrap from a dead one). If that happens, the scoped |
| 63 | `POST /api/admin/space-thread/reset` clears it without further collateral. |
| 64 | |
| 65 | ## Server surface |
| 66 | |
| 67 | | capability | endpoint | notes | |
| 68 | | ---------- | -------- | ----- | |
| 69 | | Fetch | `GET /api/space-thread?token=` | `{id, open_key}`, or `{}` before first install. Member-gated; **agents get `{}`** unless explicitly made members โ a sidecar holds the instance key, so handing it `open_key` would unwrap the very key it was excluded from (defence in depth on top of the message gate). | |
| 70 | | Create | `POST /api/space-thread` `{token, open_key}` | Find-or-create; idempotent under races (a partial unique index allows exactly one `kind='space'` row, and the stored `open_key` is never overwritten โ the `grantInstanceKey` anti-brick rule). Broadcasts a content-free `space_thread` WS prod so online clients fetch it live. | |
| 71 | | Reset | `POST /api/admin/space-thread/reset` | Admin break-glass for a wedged space thread โ chiefly a **poisoned `open_key`**: any member can create the thread and the blob is never overwritten (and can't be validated server-side), so a hostile or buggy first write would otherwise wedge space apps for good. Deletes only the space thread + its rows (journaled); the next install mints a fresh one. Scoped on purpose โ recovery mustn't cost every member their sealed name/photo the way the full instance-key reset does. | |
| 72 | |
| 73 | Everything else โ messages, state snapshots, scheduling, uploads โ is the |
| 74 | ordinary thread machinery, gated by the (now implicit) membership check. |
| 75 | `POST /api/threads` refuses `kind:"space"`, and the rename/avatar endpoints |
| 76 | refuse the space thread (it renders nowhere โ implicit membership must not |
| 77 | open a scribble surface). The one space thread is minted only through the |
| 78 | endpoint above. Server code: `spacethread.go`; tests: `spacethread_test.go`. |
| 79 | |
| 80 | ## Client model |
| 81 | |
| 82 | - **`places` including `"space"`** on `defineApp` (or in a member-app |
| 83 | manifest, where the legacy `spaceCapable: true` boolean is also accepted |
| 84 | and normalizes into it) marks an app installable for the space โ an |
| 85 | explicit trust decision per app, like the `"chat"` place, with the same |
| 86 | bar: a pure, idempotent, validating reducer, render off `ctx.threadId`, |
| 87 | per-user logic keyed on `e.uid`. No shipped app currently declares it |
| 88 | (ยง The pullback below). |
| 89 | - **ONE registry app** โ `installs` (`web/static/apps.js`) โ folds every |
| 90 | shared place's membership: `groupapps.*` events in a group's log, |
| 91 | `spaceapps.*` in the space thread's (the legacy wire families stay the wire |
| 92 | format forever โ logs are immutable; the unification is in the fold, and |
| 93 | legacy snapshots seed the unified key via seedThread's shim). Read through |
| 94 | `spaceAppsFor()` (filtered to registered, space-capable apps, so hostile or |
| 95 | stale installs stay inert) and `spaceAppInfo(name)` (the "Installed by Ana" |
| 96 | attribution). Vectors: `test/event-vectors/spaceapps.json`. |
| 97 | - **Command routing precedence** (`runCommand`): *local verbs โ the app's own |
| 98 | thread โ this chat's group instance โ the space instance โ your personal |
| 99 | thread*. A space-installed app's global command acts on the shared space |
| 100 | instance **from every chat** โ that's what "installed for everyone" means โ |
| 101 | while the same verb typed *inside* the app's own personal thread, or in a |
| 102 | group that added the app, keeps addressing the instance you're looking at |
| 103 | (the nearest scope). The `/` typeahead discloses the routing, reading on |
| 104 | from the command's desc as one sentence: "Post a sticker for this space" |
| 105 | (`placeRowPhrase`). |
| 106 | - **`/app @everyone [text]`** installs for the space in one gesture (the |
| 107 | space-wide sibling of `/todo @here`), creating the space thread on first |
| 108 | use; trailing text runs against the fresh shared instance. Non-space-capable |
| 109 | apps get a private explanation, never a mis-filed event. `@space` is an |
| 110 | accepted alias, and **`@me`** completes the family toward privacy: it files |
| 111 | into your own copy even where a shared one claims the bare verb. When |
| 112 | several copies are reachable, the `/` typeahead offers the flagged variants |
| 113 | as extra rows โ the menu teaches the keywords. The fresh case (nothing |
| 114 | placed anywhere) gets the same treatment: the flags that would *install* |
| 115 | the app ride as rows that say so in the same sentence shape โ "/todo @here" |
| 116 | reads "Add a to-do to a new To-do app for this group" (groups only), and |
| 117 | "/todo @everyone" the space twin (hidden when the admins-only policy bars |
| 118 | you) โ while rows for copies that already exist read plain routing ("Add a |
| 119 | to-do for this space"), no install language (`installRowPhrase` / |
| 120 | `placeRowPhrase`). Alternate rows always rank right behind their bare verb, |
| 121 | so an install offer never outranks the default route. |
| 122 | - **@mentions**: "@Stickers party time" works in any chat once the app is |
| 123 | space-installed; the event lands in the space log, panels and replies stay |
| 124 | where you typed. |
| 125 | - **The Apps sheet** (โฎ โ Apps in any chat โ DMs too โ and from the SPACE's |
| 126 | own sidebar menu; an OPEN chat counts as "This chat" whichever door you |
| 127 | came through) is ONE directory with a FOCUS-AND-EXPLAIN picker: every |
| 128 | app appears once, wearing badges for the places it already occupies ("This |
| 129 | chat" / "Everyone") and a โบ that focuses it โ the other rows drop away, and |
| 130 | the app grows three constant place SELECTORS (Just for you | This chat | |
| 131 | Everyone โ the sentence's and the badges' own words; the verb lives only on |
| 132 | the footer action) over an explanation panel. The selection starts on the |
| 133 | chat place when this chat could hold the app, else the personal one. Every |
| 134 | selector always shows; a place that can't act here is dimmed but stays |
| 135 | clickable, and tapping it makes the panel say exactly WHY (the app's own |
| 136 | placeNote, an inferred reason for credential-backed apps, "no chat behind |
| 137 | this menu", the admins-only policy). Available places put the real action |
| 138 | in the panel โ Open, Add, or Remove with "Added for everyone by Ana" |
| 139 | attribution โ so reading and doing are one motion. One verb โ "Add" โ |
| 140 | because the act is placement, not software installation. Groups can also |
| 141 | start life with apps: the New-group sheet's "Start with apps" picks land as |
| 142 | ordinary groupapps.add events in the fresh log (parity with native's |
| 143 | group-birth picker). The / typeahead ranks commands by the nearness of the |
| 144 | place their data lands: this chat's, the space's, then everything else. |
| 145 | - **The space band**: every space-installed app with a `render` gets its own |
| 146 | SIDEBAR ROW (badged "space", subtitle "For everyone in this space"), opening |
| 147 | the app full-pane bound to the space thread โ the copy's *home*, and its |
| 148 | only surface. Space apps deliberately do **not** ride other chats' widget |
| 149 | bars: the same toggle meaning "this chat's private list" in one chat and |
| 150 | "the whole space's" in another put the disambiguation burden on the reader |
| 151 | (place = privacy must also mean place = location). The row carries live |
| 152 | activity (the owning app's `preview`/`unread` hooks fold off the space log) |
| 153 | and the home's header repeats the orientation, so the one-time orientation |
| 154 | card now exists only for CHAT copies ("shared with everyone in this chat, |
| 155 | and only here"). **The twins problem** โ a personal "To-do" sitting right |
| 156 | beside the space's โ is met with three reinforcing cues: the space row is |
| 157 | *named after the space* ("Lazyatom To-do"; `spaceRowTitle`, falling back to |
| 158 | "Space To-do" for unset or over-long names, header included); its tile |
| 159 | pairs the *space photo* and the app glyph as equal-sized circles on the |
| 160 | diagonal โ neither identity subordinate (words still carry the state); |
| 161 | and on a collision the personal row's generic "app" chip |
| 162 | gives way to a "Just for you" place badge โ the Apps sheet's own selector |
| 163 | words โ so both twins name their side. |
| 164 | - **Replies name their place**: a command routed away from where you typed is |
| 165 | stamped with the place it acted in (`runCommand` โ the notice's muted |
| 166 | footer, in the same words the typeahead uses โ `placePhrase`). Apps can |
| 167 | additionally word it themselves via `ctx.place` ("Added โฆ to the space |
| 168 | to-do list" / "I'll remind everyone in this space: โฆ"); the framework stamp |
| 169 | never depends on that. `ctx.open` ("View to-do") shows the *instance* โ |
| 170 | the space home, a chat copy's Interface pane, or the personal app-thread โ |
| 171 | never a raw navigation to the space thread, which has no chat surface. |
| 172 | - **Who may add for everyone** is an admin policy (Admin app โ Features โ |
| 173 | "Space-wide apps: admins only"; default: any member, attributed โ the group |
| 174 | social contract one level up). The registry is E2EE fold state the server |
| 175 | can't referee, so enforcement is client-side and honest about it: the flag |
| 176 | is plaintext config served as page meta; the sheet and `@everyone` refuse |
| 177 | for non-admins ("Only admins can add apps for everyone in this space"), |
| 178 | registry items record the adder's `uid`, and `spaceAppsFor()` filters at |
| 179 | READ time โ a non-admin's install is inert while the policy holds, and |
| 180 | reappears if it's lifted (read-time keeps folds deterministic and the |
| 181 | policy reversible; pre-uid items are grandfathered). A hostile client can |
| 182 | still emit an add event; what it cannot do is make anyone else's client |
| 183 | honor it. |
| 184 | - **Boot**: `loadSpaceThread()` (after the instance key + member apps; retried |
| 185 | from `onInstanceKeyReady`) fetches, unwraps, and seeds the space thread's |
| 186 | snapshot + catch-up, so routing and the sidebar's space band see installs |
| 187 | before the first keystroke. Live `spaceapps`/app events over WS fold and |
| 188 | refresh the visible surfaces; a `space_thread` prod announces first creation. |
| 189 | |
| 190 | ## The sticker-library walkthrough |
| 191 | |
| 192 | 1. A member asks a bot to build a stickers app; the bot deploys it as a member |
| 193 | app with `spaceCapable: true` in its manifest (sandboxed, instance-key |
| 194 | encrypted โ nothing new). |
| 195 | 2. The member runs `/stickers @everyone` (or uses โฎ โ Apps โ Install). The |
| 196 | space thread is created, the install event folds for every member, and |
| 197 | `/stickers` appears in everyone's `/` typeahead in every chat. |
| 198 | 3. Anyone, in any chat, types `/stickers` โ a command panel opens right there; |
| 199 | uploads/additions emit events into the space log, so the library is one |
| 200 | shared, end-to-end-encrypted collection. |
| 201 | 4. Posting a sticker into the current chat is an ordinary encrypted attachment |
| 202 | in *that* chat's thread (`panel.attachFiles` โ the `/gif` pattern): app |
| 203 | data lives space-wide, conversation content stays under the conversation's |
| 204 | own key. |
| 205 | |
| 206 | ## The pullback (2026-07-15): places UI hidden, machinery live |
| 207 | |
| 208 | The multi-place surfaces read as too much, too soon for members โ the crisp |
| 209 | failure test: *"if there's two To-do apps in the thread list, we've failed."* |
| 210 | So the user-facing places UI is pulled back, deliberately WITHOUT reverting |
| 211 | the machinery, because the questions it answers (one app, several homes; |
| 212 | space-wide installs; the flag grammar) are still live design work: |
| 213 | |
| 214 | - **Shipped apps park at `you`+`chat`.** To-do, Reminders, Bookmarks, and the |
| 215 | Vault declare `places: ["you", "chat"]` โ no shipped app can stand in the |
| 216 | sidebar as both a personal thread and a space row, which is the twins |
| 217 | failure. Nothing else enforces this: an app returns to the space place by |
| 218 | adding `"space"` back to its one `places` line (every downstream surface โ |
| 219 | typeahead flag rows, the Apps sheet, the space band โ follows the |
| 220 | declaration). `/gif` stays `["space"]`: implicitly placed, `listed: false`, |
| 221 | no visible surface. |
| 222 | - **`PLACES_UI` (shellstate.js) gates the multi-place chrome**, default OFF, |
| 223 | read from `localStorage.lchat_places_ui` (`"1"` = on, per device, reload to |
| 224 | apply) โ a client-side experiment switch, deliberately not a server gate. |
| 225 | With it off: the ๏ผ picker's app rows go back to the direct personal |
| 226 | open/create (no places-sheet doorway); a chat's โฎ โ Apps opens the simple |
| 227 | chat-only sheet (`manageChatApps` โ add/remove shared copies for THIS chat, |
| 228 | groups only, so the item hides in DMs); and the space menu's Apps entry |
| 229 | hides. With it on, the full focus-and-explain directory and every doorway |
| 230 | come back exactly as designed above. |
| 231 | - **What stays on regardless**: chat placement everywhere it lived before the |
| 232 | places work (โฎ โ Apps in groups, "Start with apps" at group birth, `@here`, |
| 233 | the typeahead's `@here` install row), the `@me` escape hatch, and the whole |
| 234 | space machinery (thread, registry, routing, vectors) โ exercised by the |
| 235 | synthetic all-places fixtures in `test/apps.test.mjs` (`triple`, `stickers`) |
| 236 | so it can't rot while parked. |
| 237 | |
| 238 | ## Scope and follow-ups |
| 239 | |
| 240 | - **Web + server first (deliberate).** The native apps neither see the space |
| 241 | thread (it's absent from `/api/threads`) nor route commands to it yet; |
| 242 | bringing iOS (first) and Mac to parity โ native `spaceapps` folding, the |
| 243 | sheet section, catalog entries, and `SpaceAppsVectorTests` twins โ is the |
| 244 | next slice. The web vectors in `test/event-vectors/spaceapps.json` are the |
| 245 | contract they'll pin against. |
| 246 | - **Offline-first space state**: the space thread's ciphertext isn't in the |
| 247 | local store's boot snapshot yet, so a cold offline boot โ and the first |
| 248 | beat of a cached boot, before `loadSpaceThread` lands โ routes commands |
| 249 | personally. The `/` typeahead label always tells the truth about where a |
| 250 | command routes *right now*, so the window is disclosed, but extending |
| 251 | store.js hydration to the space thread is the follow-up that closes it. |
| 252 | - **Removal visibility**: group adds/removes show as action lines in the |
| 253 | conversation; space installs show only in the Apps sheet ("Installed by โฆ"). |
| 254 | A future "space activity" surface (or Admin-app note) could make removals |
| 255 | more visible. |
| 256 | - **Bots in the space thread**: excluded by default; a deliberate opt-in flow |
| 257 | (disclosed, like adding a bot to a group) would let a bot serve space-wide |
| 258 | apps โ e.g. a stickers bot that curates the shared library. |
| 259 | |