# 🤖 Space-wide apps: install once, use everywhere

The problem this solves: an app you build (or love) shouldn't need adding to
every chat, by every person, before it's useful. If someone makes a sticker
library, "install it for everyone in the space" should mean exactly that — its
slash commands work in any chat, its data is one shared library, and nobody
else installs anything.

## The design in one paragraph

A space-wide install is the group-app model **one level up**. Every instance
gets (lazily, on the first install) a single hidden **space thread** — a
`kind:"space"` thread with **implicit membership** (every member of the space,
by construction) whose AES key rides `open_key`, i.e. encrypted under the
**instance key** that every member already unwraps with their own private key
(the open-group mechanism). Which apps are installed is fold state from
`spaceapps.add` / `spaceapps.remove` events in that thread's encrypted log —
the `groupapps` registry's twin — and each installed app runs **one shared
instance** whose events and state snapshots live in the same thread. Commands,
@mentions, the sidebar's space band, scheduling, and snapshots all reuse the
existing per-thread machinery; only the thread is new.

## What the server learns (and doesn't)

Nothing changes about rule one. The server stores: one thread row of kind
`space`, one wrapped key blob it cannot open, and opaque ciphertext. It never
learns **which** apps are installed (that's inside the log), what any app's
data is, or who uses what. The two server-visible facts are that a space
thread exists and its message timings/sizes — the same metadata class as any
thread.

- **No new key material class.** The user-facing question "does this mean a
  second key per user?" has the answer *no*: members unwrap the instance key
  with their personal key (existing path, late-joiner distribution and all),
  and the instance key unwraps the space thread's key. The invariant "the
  instance key never encrypts messages" holds — it only *wraps* the space
  thread's key, exactly as it wraps every open group's.
- **Implicit membership** (`db.threadMember` / `db.memberUserIDs`): every
  non-agent member is a member of the space thread with no rows to backfill
  and no join path that can drift. **Agents are excluded** — a bot reading the
  space log must be a deliberate, disclosed choice (an explicit membership row
  can be added later), mirroring `joinDefaultThreads`.
- **Broadcast goes to everyone, and that's correct**: every member can decrypt
  by design, so WS fan-out to all members leaks nothing. **Push is skipped**
  for the space thread (`pushNewMessage` / `pushScheduled`): registry churn
  and app traffic must not buzz every phone, and clients that don't know the
  thread couldn't render the notification. Live WS delivery still applies, so
  open clients stay current; a space app that wants notification-grade voice
  is a future, deliberate design.
- **Invisible to old clients.** `threadsForUser` joins on membership rows, so
  the space thread never appears in `GET /api/threads` — web clients that
  predate this, and the native apps until they learn the feature, never see
  it. New clients fetch it explicitly (`GET /api/space-thread`).
- **The break-glass instance-key reset deletes it.** Unlike an open group,
  members hold no per-member wraps for the space thread — `open_key` under the
  instance key is the *only* path to its key — so after a reset its log is
  unreadable forever. `resetInstanceKey` deletes the thread (journaled, inside
  the same transaction so a racing create can't slip past), its messages,
  state, schedules, and blobs; the next install starts fresh. One residual: a
  tab left open **across** the reset still holds the dead instance key in
  memory and could re-mint the space thread wrapped under it (the server
  can't tell a live wrap from a dead one). If that happens, the scoped
  `POST /api/admin/space-thread/reset` clears it without further collateral.

## Server surface

| capability | endpoint | notes |
| ---------- | -------- | ----- |
| 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). |
| 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. |
| 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. |

Everything else — messages, state snapshots, scheduling, uploads — is the
ordinary thread machinery, gated by the (now implicit) membership check.
`POST /api/threads` refuses `kind:"space"`, and the rename/avatar endpoints
refuse the space thread (it renders nowhere — implicit membership must not
open a scribble surface). The one space thread is minted only through the
endpoint above. Server code: `spacethread.go`; tests: `spacethread_test.go`.

## Client model

- **`places` including `"space"`** on `defineApp` (or in a member-app
  manifest, where the legacy `spaceCapable: true` boolean is also accepted
  and normalizes into it) marks an app installable for the space — an
  explicit trust decision per app, like the `"chat"` place, with the same
  bar: a pure, idempotent, validating reducer, render off `ctx.threadId`,
  per-user logic keyed on `e.uid`. No shipped app currently declares it
  (§ The pullback below).
- **ONE registry app** — `installs` (`web/static/apps.js`) — folds every
  shared place's membership: `groupapps.*` events in a group's log,
  `spaceapps.*` in the space thread's (the legacy wire families stay the wire
  format forever — logs are immutable; the unification is in the fold, and
  legacy snapshots seed the unified key via seedThread's shim). Read through
  `spaceAppsFor()` (filtered to registered, space-capable apps, so hostile or
  stale installs stay inert) and `spaceAppInfo(name)` (the "Installed by Ana"
  attribution). Vectors: `test/event-vectors/spaceapps.json`.
- **Command routing precedence** (`runCommand`): *local verbs → the app's own
  thread → this chat's group instance → the space instance → your personal
  thread*. A space-installed app's global command acts on the shared space
  instance **from every chat** — that's what "installed for everyone" means —
  while the same verb typed *inside* the app's own personal thread, or in a
  group that added the app, keeps addressing the instance you're looking at
  (the nearest scope). The `/` typeahead discloses the routing, reading on
  from the command's desc as one sentence: "Post a sticker for this space"
  (`placeRowPhrase`).
- **`/app @everyone [text]`** installs for the space in one gesture (the
  space-wide sibling of `/todo @here`), creating the space thread on first
  use; trailing text runs against the fresh shared instance. Non-space-capable
  apps get a private explanation, never a mis-filed event. `@space` is an
  accepted alias, and **`@me`** completes the family toward privacy: it files
  into your own copy even where a shared one claims the bare verb. When
  several copies are reachable, the `/` typeahead offers the flagged variants
  as extra rows — the menu teaches the keywords. The fresh case (nothing
  placed anywhere) gets the same treatment: the flags that would *install*
  the app ride as rows that say so in the same sentence shape — "/todo @here"
  reads "Add a to-do to a new To-do app for this group" (groups only), and
  "/todo @everyone" the space twin (hidden when the admins-only policy bars
  you) — while rows for copies that already exist read plain routing ("Add a
  to-do for this space"), no install language (`installRowPhrase` /
  `placeRowPhrase`). Alternate rows always rank right behind their bare verb,
  so an install offer never outranks the default route.
- **@mentions**: "@Stickers party time" works in any chat once the app is
  space-installed; the event lands in the space log, panels and replies stay
  where you typed.
- **The Apps sheet** (⋮ → Apps in any chat — DMs too — and from the SPACE's
  own sidebar menu; an OPEN chat counts as "This chat" whichever door you
  came through) is ONE directory with a FOCUS-AND-EXPLAIN picker: every
  app appears once, wearing badges for the places it already occupies ("This
  chat" / "Everyone") and a › that focuses it — the other rows drop away, and
  the app grows three constant place SELECTORS (Just for you | This chat |
  Everyone — the sentence's and the badges' own words; the verb lives only on
  the footer action) over an explanation panel. The selection starts on the
  chat place when this chat could hold the app, else the personal one. Every
  selector always shows; a place that can't act here is dimmed but stays
  clickable, and tapping it makes the panel say exactly WHY (the app's own
  placeNote, an inferred reason for credential-backed apps, "no chat behind
  this menu", the admins-only policy). Available places put the real action
  in the panel — Open, Add, or Remove with "Added for everyone by Ana"
  attribution — so reading and doing are one motion. One verb — "Add" —
  because the act is placement, not software installation. Groups can also
  start life with apps: the New-group sheet's "Start with apps" picks land as
  ordinary groupapps.add events in the fresh log (parity with native's
  group-birth picker). The / typeahead ranks commands by the nearness of the
  place their data lands: this chat's, the space's, then everything else.
- **The space band**: every space-installed app with a `render` gets its own
  SIDEBAR ROW (badged "space", subtitle "For everyone in this space"), opening
  the app full-pane bound to the space thread — the copy's *home*, and its
  only surface. Space apps deliberately do **not** ride other chats' widget
  bars: the same toggle meaning "this chat's private list" in one chat and
  "the whole space's" in another put the disambiguation burden on the reader
  (place = privacy must also mean place = location). The row carries live
  activity (the owning app's `preview`/`unread` hooks fold off the space log)
  and the home's header repeats the orientation, so the one-time orientation
  card now exists only for CHAT copies ("shared with everyone in this chat,
  and only here"). **The twins problem** — a personal "To-do" sitting right
  beside the space's — is met with three reinforcing cues: the space row is
  *named after the space* ("Lazyatom To-do"; `spaceRowTitle`, falling back to
  "Space To-do" for unset or over-long names, header included); its tile
  pairs the *space photo* and the app glyph as equal-sized circles on the
  diagonal — neither identity subordinate (words still carry the state);
  and on a collision the personal row's generic "app" chip
  gives way to a "Just for you" place badge — the Apps sheet's own selector
  words — so both twins name their side.
- **Replies name their place**: a command routed away from where you typed is
  stamped with the place it acted in (`runCommand` → the notice's muted
  footer, in the same words the typeahead uses — `placePhrase`). Apps can
  additionally word it themselves via `ctx.place` ("Added … to the space
  to-do list" / "I'll remind everyone in this space: …"); the framework stamp
  never depends on that. `ctx.open` ("View to-do") shows the *instance* —
  the space home, a chat copy's Interface pane, or the personal app-thread —
  never a raw navigation to the space thread, which has no chat surface.
- **Who may add for everyone** is an admin policy (Admin app → Features →
  "Space-wide apps: admins only"; default: any member, attributed — the group
  social contract one level up). The registry is E2EE fold state the server
  can't referee, so enforcement is client-side and honest about it: the flag
  is plaintext config served as page meta; the sheet and `@everyone` refuse
  for non-admins ("Only admins can add apps for everyone in this space"),
  registry items record the adder's `uid`, and `spaceAppsFor()` filters at
  READ time — a non-admin's install is inert while the policy holds, and
  reappears if it's lifted (read-time keeps folds deterministic and the
  policy reversible; pre-uid items are grandfathered). A hostile client can
  still emit an add event; what it cannot do is make anyone else's client
  honor it.
- **Boot**: `loadSpaceThread()` (after the instance key + member apps; retried
  from `onInstanceKeyReady`) fetches, unwraps, and seeds the space thread's
  snapshot + catch-up, so routing and the sidebar's space band see installs
  before the first keystroke. Live `spaceapps`/app events over WS fold and
  refresh the visible surfaces; a `space_thread` prod announces first creation.

## The sticker-library walkthrough

1. A member asks a bot to build a stickers app; the bot deploys it as a member
   app with `spaceCapable: true` in its manifest (sandboxed, instance-key
   encrypted — nothing new).
2. The member runs `/stickers @everyone` (or uses ⋮ → Apps → Install). The
   space thread is created, the install event folds for every member, and
   `/stickers` appears in everyone's `/` typeahead in every chat.
3. Anyone, in any chat, types `/stickers` — a command panel opens right there;
   uploads/additions emit events into the space log, so the library is one
   shared, end-to-end-encrypted collection.
4. Posting a sticker into the current chat is an ordinary encrypted attachment
   in *that* chat's thread (`panel.attachFiles` — the `/gif` pattern): app
   data lives space-wide, conversation content stays under the conversation's
   own key.

## The pullback (2026-07-15): places UI hidden, machinery live

The multi-place surfaces read as too much, too soon for members — the crisp
failure test: *"if there's two To-do apps in the thread list, we've failed."*
So the user-facing places UI is pulled back, deliberately WITHOUT reverting
the machinery, because the questions it answers (one app, several homes;
space-wide installs; the flag grammar) are still live design work:

- **Shipped apps park at `you`+`chat`.** To-do, Reminders, Bookmarks, and the
  Vault declare `places: ["you", "chat"]` — no shipped app can stand in the
  sidebar as both a personal thread and a space row, which is the twins
  failure. Nothing else enforces this: an app returns to the space place by
  adding `"space"` back to its one `places` line (every downstream surface —
  typeahead flag rows, the Apps sheet, the space band — follows the
  declaration). `/gif` stays `["space"]`: implicitly placed, `listed: false`,
  no visible surface.
- **`PLACES_UI` (shellstate.js) gates the multi-place chrome**, default OFF,
  read from `localStorage.lchat_places_ui` (`"1"` = on, per device, reload to
  apply) — a client-side experiment switch, deliberately not a server gate.
  With it off: the ＋ picker's app rows go back to the direct personal
  open/create (no places-sheet doorway); a chat's ⋮ → Apps opens the simple
  chat-only sheet (`manageChatApps` — add/remove shared copies for THIS chat,
  groups only, so the item hides in DMs); and the space menu's Apps entry
  hides. With it on, the full focus-and-explain directory and every doorway
  come back exactly as designed above.
- **What stays on regardless**: chat placement everywhere it lived before the
  places work (⋮ → Apps in groups, "Start with apps" at group birth, `@here`,
  the typeahead's `@here` install row), the `@me` escape hatch, and the whole
  space machinery (thread, registry, routing, vectors) — exercised by the
  synthetic all-places fixtures in `test/apps.test.mjs` (`triple`, `stickers`)
  so it can't rot while parked.

## Scope and follow-ups

- **Web + server first (deliberate).** The native apps neither see the space
  thread (it's absent from `/api/threads`) nor route commands to it yet;
  bringing iOS (first) and Mac to parity — native `spaceapps` folding, the
  sheet section, catalog entries, and `SpaceAppsVectorTests` twins — is the
  next slice. The web vectors in `test/event-vectors/spaceapps.json` are the
  contract they'll pin against.
- **Offline-first space state**: the space thread's ciphertext isn't in the
  local store's boot snapshot yet, so a cold offline boot — and the first
  beat of a cached boot, before `loadSpaceThread` lands — routes commands
  personally. The `/` typeahead label always tells the truth about where a
  command routes *right now*, so the window is disclosed, but extending
  store.js hydration to the space thread is the follow-up that closes it.
- **Removal visibility**: group adds/removes show as action lines in the
  conversation; space installs show only in the Apps sheet ("Installed by …").
  A future "space activity" surface (or Admin-app note) could make removals
  more visible.
- **Bots in the space thread**: excluded by default; a deliberate opt-in flow
  (disclosed, like adding a bot to a group) would let a bot serve space-wide
  apps — e.g. a stickers bot that curates the shared library.
