@dxos/halo 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/API_AUDIT.md ADDED
@@ -0,0 +1,396 @@
1
+ # HALO API Audit — Composer & Plugins
2
+
3
+ Inventory of every HALO-related API from `@dxos/client` / `@dxos/react-client` consumed by
4
+ Composer (`packages/apps/composer-app`) and the plugins (`packages/plugins/*`), followed by an
5
+ evaluation of how `@dxos/halo` Effect services can replace that surface so application code no
6
+ longer depends on `@dxos/client` directly.
7
+
8
+ Companion to [MIGRATION.md](./MIGRATION.md) (HALO → Keyhive). The migration plan describes the
9
+ _backend_ rewrite; this document describes the _consumer-facing_ seam that must exist regardless
10
+ of which backend (legacy client, EDGE shim, or Keyhive) is active — see MIGRATION.md §A.2.
11
+
12
+ Method: static audit (2026-07-13) of `packages/apps/composer-app` and `packages/plugins`,
13
+ excluding tests. Storybook fixtures are included but flagged, since they define the testing
14
+ surface the new package must also serve.
15
+
16
+ ## 1. Entry points
17
+
18
+ Import counts across Composer + plugins (production code and stories):
19
+
20
+ | Import source | Files | Notes |
21
+ | -------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------- |
22
+ | `@dxos/react-client/echo` | 268 | Mostly ECHO db/query hooks (out of scope), but also `useSpaces`, `useSpace`, `useMembers`, `useSpaceInvitations`, `SpaceMember`, `SpaceState` |
23
+ | `@dxos/client` | 88 | `Client` type, `ClientService`, `PublicKey` re-export |
24
+ | `@dxos/react-client` | 64 | `useClient`, `useConfig`, `useMulticastObservable` |
25
+ | `@dxos/client/echo` | 52 | `getSpace`, `Space`, `SpaceState`, `SpaceMember` |
26
+ | `@dxos/react-client/halo` | 26 | `useIdentity`, `useDevices`, `useCredentials`, `Identity`, `Device`, `Credential` types |
27
+ | `@dxos/client/invitations` | 7 | `Invitation`, `InvitationEncoder`, `hostInvitation`, observables |
28
+ | `@dxos/react-client/invitations` | 4 | Same, from React entry point |
29
+ | `@dxos/client/halo` | 4 | `Identity`, `Device`, `DeviceKind`, `DeviceType`, `Credential` |
30
+ | `@dxos/client/edge` | 5 | `createEdgeIdentity` (credential-presentation auth) |
31
+ | `@dxos/shell/react` | 2+ | `SpaceMemberList`, `InvitationList`, `AuthCode` UI components |
32
+
33
+ Almost all plugins obtain the client via the `ClientCapabilities.Client` capability provided by
34
+ `plugin-client` — the capability system is already the single injection point, which makes the
35
+ service swap tractable.
36
+
37
+ ## 2. API surface by aspect
38
+
39
+ ### 2.1 Identity
40
+
41
+ `client.halo.identity` (a `MulticastObservable<Identity | null>`) is by far the most used API
42
+ (~40 call sites + 20 hook usages).
43
+
44
+ | API | Count | Consumers |
45
+ | ------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | `halo.identity.get()` | ~40 | Gating ("has identity?"): plugin-space (`navigation-handler`, `operations/join`, `spaces-ready`), plugin-registry, plugin-onboarding, plugin-client CLI commands. Reading fields (`identityKey`, `did`, `spaceKey`, `profile`): composer-app recovery pages, plugin-script deploy, plugin-transcription, plugin-meeting, plugin-iroh-beacon |
47
+ | `halo.identity.subscribe()` | 5 | plugin-client (`capabilities/client.ts` — layout mode on login), plugin-calls (`call-manager`), plugin-onboarding (`onboarding-manager`), plugin-assistant (`edge-model-resolver`) |
48
+ | `CreateAtom.fromObservable(identity)` | 1 | plugin-client `app-graph-builder` — bridges the observable into the atom-based graph |
49
+ | `useIdentity()` hook | ~20 | plugin-assistant (Chat), plugin-thread, plugin-comments, plugin-markdown (collab identity), plugin-code, plugin-script, plugin-space (SpacePresence), plugin-transcription, plugin-onboarding (WelcomeScreen), plugin-client (Account/Profile containers) |
50
+ | `halo.createIdentity(profile?)` | 14 | Operation handler (`plugin-client/operations/create-identity`), testing (`initializeIdentity`), ~10 story fixtures, import scripts (plugin-inbox, plugin-onboarding) |
51
+ | `halo.recoverIdentity({...})` | 4 | plugin-client `account login` and `halo recover` CLI commands |
52
+ | `halo.updateProfile(profile)` | 1 | plugin-client `ProfileContainer` |
53
+ | `identity.did` / `identityKey` / `spaceKey` / `profile` | many | Field reads wherever identity is obtained |
54
+
55
+ ### 2.2 Devices
56
+
57
+ | API | Count | Consumers |
58
+ | ------------------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------- |
59
+ | `halo.device` | 6 | plugin-client `device info/update` commands, plugin-calls (presence key), plugin-iroh-beacon (`deviceKey`) |
60
+ | `halo.devices.get()` / `useDevices()` | 4 | plugin-client `DevicesContainer`, `device list` command, plugin-onboarding (`onboarding-manager` — device count gates recovery hints) |
61
+ | `Device`, `DeviceKind`, `DeviceType` types | 3 | plugin-client device UI/CLI formatting |
62
+ | `DevicesService.updateDevice` (raw RPC) | 1 | plugin-client `device update` command — no proxy-level API exists |
63
+
64
+ ### 2.3 Credentials & recovery
65
+
66
+ | API | Count | Consumers |
67
+ | ---------------------------------------------------- | ----- | ---------------------------------------------------------------------------------------------------------------- |
68
+ | `halo.credentials.subscribe()` / `useCredentials()` | 3 | plugin-client `credential list` command, `RecoveryCredentialsContainer` (filters `IdentityRecovery` credentials) |
69
+ | `halo.queryCredentials({ type })` | 1 | plugin-client `credential list` |
70
+ | `halo.writeCredentials([...])` | 2 | plugin-client `credential add`; plugin-script (writes a `ServiceAccess` credential before deploy) |
71
+ | `space.internal.getCredentials()` | 1 | plugin-client `credential list --space` |
72
+ | `SpacesService.queryCredentials` (raw RPC) | 1 | composer-app `util/halo.ts` `queryAllCredentials` — works around `queryCredentials` being sync/incomplete |
73
+ | `IdentityService.createRecoveryCredential` (raw RPC) | 3 | plugin-client `create-recovery-code`, `create-passkey` operations |
74
+ | `IdentityService.requestRecoveryChallenge` (raw RPC) | 1 | plugin-client `redeem-passkey` operation |
75
+ | `IdentityService.recoverIdentity` (raw RPC) | 2 | plugin-client `redeem-passkey`, `redeem-token` operations (proof/token variants not on the proxy) |
76
+
77
+ The recovery/passkey flows reach past `client.halo` into raw RPC services because the proxy API
78
+ is incomplete — a clear signal for what the new service must cover as first-class verbs.
79
+
80
+ ### 2.4 EDGE identity (credential presentation)
81
+
82
+ `createEdgeIdentity(client)` builds an `EdgeIdentity` whose `presentCredentials({ challenge })`
83
+ signs a nonce challenge with the device key — HALO's auth handshake toward EDGE.
84
+
85
+ | Consumer | Site |
86
+ | ---------------- | --------------------------------------------------------------------- |
87
+ | plugin-assistant | `edge-model-resolver` (AI service auth) |
88
+ | plugin-connector | `connector-coordinator` (EDGE websocket auth) |
89
+ | plugin-payments | `services/edge-auth` (`Authorization: VerifiablePresentation` header) |
90
+ | plugin-client | `useHubClient`, `edge status` command |
91
+
92
+ ### 2.5 Space management
93
+
94
+ Space CRUD/lifecycle — HALO-adjacent because membership and space keys are credential-backed:
95
+
96
+ | API | Count | Consumers |
97
+ | ---------------------------------------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
98
+ | `client.spaces.get()` / `.get(id)` | 57 | Ubiquitous space resolution: plugin-space, plugin-assistant, plugin-inbox, plugin-wnfs, plugin-search, plugin-thread, plugin-meeting, plugin-connector, plugin-discord/bluesky/slack sync, plugin-deck, plugin-simple-layout, composer-app recovery |
99
+ | `client.spaces.create(props?, options?)` | 34 | plugin-space `operations/create`, personal-space bootstrap (`identity-created`, `initializeIdentity`, `halo create` — with `tags`, `membershipPolicy`), plugin-native-filesystem mirror space, ~20 story fixtures |
100
+ | `client.spaces.subscribe()` | 8 | plugin-space (`spaces-ready` ×3, `save-tracker`, `JoinDialog`), plugin-client (`capabilities/client.ts`), plugin-routine (`trigger-runtime-controller`) |
101
+ | `client.spaces.join(code)` | 1 | plugin-space `space join` command |
102
+ | `client.spaces.import(archive, opts?)` | 4 | plugin-space `operations/import-space`, plugin-onboarding exemplar space, plugin-assistant snapshot testing |
103
+ | `space.waitUntilReady()` | ~20 | Everywhere a space is created/joined/opened |
104
+ | `space.state.get()` / `SpaceState` | 4 | plugin-doctor, plugin-space `react-surface` |
105
+ | `useSpaces()` / `useSpace(id)` | ~80 | plugin-space containers + story fixtures across ~25 plugins |
106
+ | `space.internal.export({ format })` | 3 | plugin-space `operations/export-space`, onboarding/inbox scripts |
107
+ | `space.internal.migrate()` / `db.runMigrations` | 3 | plugin-space `operations/migrate`, `spaces-ready`; plugin-client `migrations` |
108
+ | `space.internal.setEdgeReplicationPreference` / `data.edgeReplication` | 6 | plugin-space (`operations/create`, `spaces-ready`, sync-status UI), plugin-client `halo create` |
109
+ | `space.internal.db.saveStateChanged` | 2 | plugin-space `save-tracker` |
110
+ | `AppSpace.*` (`@dxos/app-toolkit`) | ~48 | `getPersonalSpace`, `getActiveSpace`, `isPersonalSpace`, tags — an existing app-level façade over `client.spaces` |
111
+
112
+ Note: `space.db` / ECHO query APIs (the 268 `@dxos/react-client/echo` imports) are the ECHO
113
+ migration track (`Database` service in `@dxos/echo`), not HALO — they are out of scope here
114
+ except where noted.
115
+
116
+ ### 2.6 Membership & presence
117
+
118
+ | API | Count | Consumers |
119
+ | --------------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------- |
120
+ | `useMembers(spaceKey)` | 7 | plugin-space (`SpacePresence`, `SpaceHomeDashboard`), plugin-thread (Channel/Thread), plugin-comments, plugin-transcription |
121
+ | `SpaceMember` type / `PresenceState` | 6 | plugin-space `members` command + presence UI, plugin-thread |
122
+ | `member.presence`, `member.identity` | 6 | Same |
123
+ | `SpaceMemberList` (`@dxos/shell/react`) | 2 | plugin-space `MembersContainer` (annotated `TODO: Copied from Shell`) |
124
+
125
+ No consumer calls role management (`updateMemberRole`) or member removal today — the UI simply
126
+ doesn't offer it. The new API should still define these verbs (Keyhive makes revocation
127
+ first-class).
128
+
129
+ ### 2.7 Invitations
130
+
131
+ Device (HALO) invitations:
132
+
133
+ | API | Consumers |
134
+ | ------------------------------ | ----------------------------------------------------------------------- |
135
+ | `client.halo.share(opts?)` | plugin-client `DevicesContainer` (QR + auth code), `halo share` command |
136
+ | `client.halo.join(invitation)` | plugin-client `halo join`, `account login` commands |
137
+
138
+ Space invitations:
139
+
140
+ | API | Consumers |
141
+ | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
142
+ | `space.share({ type, authMethod, multiUse, target })` | plugin-space `operations/share`, `operations/get-share-link` (delegated + known-public-key), `MembersContainer` (interactive + delegated), `space share` command |
143
+ | `client.spaces.join(code)` | plugin-space `space join` command |
144
+ | `useSpaceInvitations(spaceKey)` | plugin-space `MembersContainer` |
145
+
146
+ Shared machinery:
147
+
148
+ | API | Consumers |
149
+ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
150
+ | `InvitationEncoder.encode/decode` | 12 sites (plugin-client, plugin-space) |
151
+ | `Invitation.State` (state-machine polling) | 20 sites — CLI `waitForState`, dialog state UIs |
152
+ | `Invitation.Type` (`INTERACTIVE`/`DELEGATED`), `Invitation.AuthMethod` (`SHARED_SECRET`/`KNOWN_PUBLIC_KEY`) | plugin-space share/get-share-link, operation schemas (`Schema.Enums(Invitation.Type)`) |
153
+ | `CancellableInvitationObservable` / `AuthenticatingInvitationObservable` | Devices/Members containers, CLI utils |
154
+ | `hostInvitation` helper | plugin-client `halo share`, plugin-space `space share` |
155
+ | `InvitationResult` | Join dialogs (plugin-client, plugin-space) |
156
+
157
+ ### 2.8 Existing verb layer (key observation)
158
+
159
+ Composer already routes most HALO _mutations_ through `Operation` definitions rather than
160
+ calling the client ad hoc:
161
+
162
+ - `plugin-client` operations: `CreateIdentity`, `JoinIdentity`, `ShareIdentity`,
163
+ `RecoverIdentity`, `CreateRecoveryCode`, `CreatePasskey`, `RedeemPasskey`, `RedeemToken`,
164
+ `ResetStorage`, `CreateAgent`.
165
+ - `plugin-space` operations: `Create`, `Join`, `Share`, `GetShareLink`, `ImportSpace`,
166
+ `ExportSpace`, `Migrate`, `Rename`, `OpenMembers`, …
167
+
168
+ The operation _handlers_ are where `client.*` calls concentrate. What is **not** funneled:
169
+ reads and subscriptions (`identity.get()`, `useIdentity`, `useSpaces`, `useMembers`,
170
+ `spaces.subscribe`), invitation observables, types/enums, and the raw-RPC escape hatches
171
+ (§2.3). Replacing the client dependency therefore mostly means giving the handlers, hooks, and
172
+ types a new home — not redesigning plugin control flow.
173
+
174
+ ### 2.9 Out of scope
175
+
176
+ - `plugin-devtools` — deliberately introspects the whole client stack; migrates last (or stays
177
+ on `@dxos/client` as a diagnostic tool).
178
+ - `SystemService.reset`, `client.diagnostics()` — client lifecycle, not HALO.
179
+ - ECHO database APIs (`space.db`, `useQuery`, …) — separate `@dxos/echo` Database service track.
180
+ - `plugin-observability` identity provider — reads raw services for telemetry; follows once the
181
+ services exist.
182
+
183
+ ## 3. Evaluation: replacing the surface with `@dxos/halo` services
184
+
185
+ ### 3.1 Shape
186
+
187
+ **`@dxos/halo` holds definitions only — no implementations.** The package exports service tags
188
+ (`Context.Tag`), their interfaces, verb functions (module-level `Effect`s that consume the tag),
189
+ and the Effect schemas for the domain types. Every concrete backing — the client adapter, the
190
+ Keyhive/EDGE layers, any in-memory test double — lives in a **separate implementation package**
191
+ and is provided as a `Layer` at composition time. This keeps `@dxos/halo` free of `@dxos/client`
192
+ (and of `keyhive_wasm`) and makes the backend a layer swap. (The Keyhive membership prototype has
193
+ been removed from this package to honor this rule; a future `@dxos/halo-keyhive` will host that
194
+ runtime — see §3.5.)
195
+
196
+ Two services (`Context.Tag`s) plus a serviceless `Invitation` module:
197
+
198
+ - **`Invitation`** — invitation _types + flow verbs_ only (no service tag): the `Flow` handle,
199
+ its event stream, authenticate/cancel/code. It does **not** initiate invitations nor query the
200
+ active set.
201
+ - **`Identity`** — identity & device management, **plus** device invitations: _initiation_
202
+ (`share`/`join`) and _querying_ (`invitations`, a current-value stream). Uses `Invitation`'s
203
+ `Flow`.
204
+ - **`Space`** — space management & membership, **plus** space invitations: _initiation_
205
+ (`share`/`join`) and _querying_ (`invitations(id)`, a current-value stream). Uses
206
+ `Invitation`'s `Flow`.
207
+
208
+ ```text
209
+ Identity ─────┐
210
+ ├──▶ Invitation (types + flow verbs; depends on neither)
211
+ Space ────────┘
212
+ ```
213
+
214
+ ```ts
215
+ import { Effect } from 'effect';
216
+ import { Identity, Invitation, Space } from '@dxos/halo';
217
+
218
+ // Initiation lives on Identity/Space; the returned flow is an Invitation.Flow:
219
+ const program = Effect.gen(function* () {
220
+ const flow = yield* Space.share(space.id); // Space.Service → Invitation.Flow
221
+ yield* Invitation.authenticate(flow, code); // flow verbs drive the lifecycle (no service)
222
+ });
223
+ ```
224
+
225
+ The split matches the audit: identity/device consumers (§2.1–2.4) rarely touch spaces;
226
+ space-resolution consumers (§2.5–2.6) rarely touch identity; the invitation _lifecycle_ (§2.7) is
227
+ a self-contained state-machine world. Putting _initiation_ on `Identity`/`Space` means a consumer
228
+ that only shares a space depends on `Space` (+ `Invitation`) without pulling in identity, while
229
+ `Invitation` stays a leaf dependency reusable by both.
230
+
231
+ Reactivity: the current API mixes `MulticastObservable`, callbacks, and React hooks. The
232
+ services expose each reactive read as an `Effect` (snapshot) plus a `Stream`/`Atom` (changes)
233
+ — plugin-client already bridges to atoms via `CreateAtom.fromObservable`, and the app graph is
234
+ atom-based, so `Atom` is the natural currency for the React layer.
235
+
236
+ ### 3.2 `Identity.Service` — identity & device management
237
+
238
+ | Verb | Replaces |
239
+ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
240
+ | `Identity.identity` → `Stream<Option<Profile>>` (current-value) | `halo.identity.get()`, `halo.identity.subscribe()`, `useIdentity` |
241
+ | `Identity.create(profile?)` | `halo.createIdentity()` |
242
+ | `Identity.updateProfile(profile)` | `halo.updateProfile()` |
243
+ | `Identity.recover({ code \| token \| proof })` | `halo.recoverIdentity()` + raw `IdentityService.recoverIdentity` (§2.3) |
244
+ | `Identity.createRecoveryKey({ key, algorithm, lookupKey })` | raw `IdentityService.createRecoveryCredential` (recovery code + passkey) |
245
+ | `Identity.requestRecoveryChallenge()` | raw `IdentityService.requestRecoveryChallenge` |
246
+ | `Identity.devices` → `Stream` (current-value) | `halo.devices`, `useDevices` |
247
+ | `Identity.localDevice` | `halo.device` |
248
+ | `Identity.updateDevice(profile)` | raw `DevicesService.updateDevice` |
249
+ | `Identity.attest({ challenge, audience? })` | `createEdgeIdentity(client).presentCredentials()` (§2.4) — signed, audience-bound message per MIGRATION.md §5.1 |
250
+ | `Identity.share(opts?)` → `Invitation.Flow` | `client.halo.share()` — device-invitation initiation (delegates the lifecycle to `Invitation`) |
251
+ | `Identity.join(code)` → `Invitation.Flow` | `client.halo.join()` — device/account join |
252
+
253
+ Types: device kind and the identity profile fields are inlined into `Identity` (`DeviceKind`,
254
+ `DeviceInfo`, `Info`) rather than living in separate schema modules. The legacy `Identity` proxy
255
+ type (`did`, `identityKey`, `spaceKey`, `profile`) collapses into `Info` = `{ did, displayName? }`
256
+ — `spaceKey` consumers (composer-app `queryAllCredentials`, plugin-onboarding `util.ts`) are
257
+ credential plumbing, deferred with the credential verbs below.
258
+
259
+ The table above is the full target surface. The **first shipped cut** (this PR) implements the
260
+ common path — `identity`, `create`, `recover`, `updateProfile`, `devices`, `share`/`join`,
261
+ `invitations` (each reactive read a single current-value stream). The remaining verbs are
262
+ **deferred** to keep the package definitions-only and
263
+ lean: `createRecoveryKey` / `requestRecoveryChallenge`, `localDevice` / `updateDevice`, `attest`,
264
+ and the transitional `credentials` / `writeCredential` pair. Credentials in particular dissolve
265
+ into Keyhive membership ops and would drag the protobuf `Credential` type into this
266
+ definitions-only package, so recovery/credential flows stay on the raw `IdentityService` until
267
+ that type is modeled here. The plugin-script `ServiceAccess` credential should become an explicit
268
+ capability-grant verb when the EDGE shim lands.
269
+
270
+ ### 3.3 `Space.Service` — space management & membership
271
+
272
+ | Verb | Replaces |
273
+ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
274
+ | `Space.spaces` → `Stream` (current-value) | `client.spaces.get()`, `client.spaces.subscribe()`, `useSpaces` |
275
+ | `Space.get(id)` → `Option<Space>` | `client.spaces.get(id)`, `useSpace(id)` |
276
+ | `Space.create(props?, { tags?, membershipPolicy? })` | `client.spaces.create()` |
277
+ | `Space.waitReady(id)` (or `Space.state(id): Stream`) | `space.waitUntilReady()`, `space.state.get()`, `SpaceState` |
278
+ | `Space.update(id, { name, icon, hue, tags })` | direct property writes (plugin-space rename) |
279
+ | `Space.members(id)` → `Stream` (current-value) | `useMembers`, `SpaceMember` reads |
280
+ | `Space.updateMemberRole(id, subject, role)` / `Space.removeMember(id, subject)` | Keyhive delegation/revocation (no current consumer). Shipped: adapter maps to `space.updateMemberRole` (member resolved by DID) |
281
+ | `Space.export(id, { format })` / `Space.import(archive, opts?)` | `space.internal.export()`, `client.spaces.import()` |
282
+ | `Space.migrate(id)` | `space.internal.migrate()`, `db.runMigrations` |
283
+ | `Space.setReplication(id, setting)` / `Space.replication(id)` | `space.internal.setEdgeReplicationPreference`, `data.edgeReplication` (becomes "delegate `pull` to EDGE" under Keyhive, MIGRATION.md §5.1) |
284
+ | `Space.share(id, { type, authMethod, multiUse, target })` → `Invitation.Flow` | `space.share()` — space-invitation initiation (delegates the lifecycle to `Invitation`) |
285
+ | `Space.join(code)` → `Invitation.Flow` | `client.spaces.join()` |
286
+
287
+ The returned `Space` value is a plain data snapshot (id, state, properties, tags) — **not** a
288
+ live proxy owning `db`. Database access stays on the `@dxos/echo` `Database` service keyed by
289
+ `SpaceId`, which decouples the two migrations. `AppSpace` (`@dxos/app-toolkit`) keeps its role
290
+ as the app-level façade (personal space, active space) but re-implements over `Space.Service`,
291
+ which converts its ~48 call sites for free.
292
+
293
+ Roles map forward to Keyhive `Access` (`read`/`edit`/`admin`/`pull`) per MIGRATION.md §3;
294
+ member `presence` is not credential data and should move to a presence/network concern rather
295
+ than ride on `SpaceMember`.
296
+
297
+ ### 3.4 `Invitation` — invitation flow types & verbs (no service)
298
+
299
+ The `Invitation` module is **types + flow-level verbs only** — there is no service tag.
300
+ Initiation is `Identity.share`/`join` (§3.2) and `Space.share`/`join` (§3.3), which construct and
301
+ return an `Invitation.Flow`; querying the active set is `Identity.invitations` (device) and
302
+ `Space.invitations` (space). The `Flow` handle carries its own lifecycle, so its verbs need no
303
+ service. `Invitation` depends on neither `Identity` nor `Space`.
304
+
305
+ | Verb / type | Replaces |
306
+ | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
307
+ | `Invitation.Flow`: `{ events: Stream<InvitationEvent>; id }` | `CancellableInvitationObservable`, `AuthenticatingInvitationObservable` (the object `Identity`/`Space` initiation returns) |
308
+ | `Invitation.authenticate(flow, code)` | `observable.authenticate()` / `Invitation.State` polling |
309
+ | `Invitation.cancel(flow)` | `observable.cancel()` |
310
+ | `Invitation.events(flow)` → `Stream<InvitationEvent>` | `Invitation.State` polling, `hostInvitation`/`waitForState` helpers |
311
+ | `Space.invitations(id)` / `Identity.invitations` (+ `*Changes`) | `useSpaceInvitations` / `useHaloInvitations` |
312
+ | `Invitation.code(flow)` / decode at `Identity.join`/`Space.join` | `InvitationEncoder` |
313
+ | Schemas: `InvitationKind`, `AuthMethod`, `Type`, `InvitationEvent` (tagged union) | `Invitation.State/Type/AuthMethod` protobuf enums (also used in operation schemas via `Schema.Enums`) |
314
+
315
+ Modeling the flow as a `Stream` of tagged events (`connecting` → `readyForAuth(authCode?)` →
316
+ `success(result)` | `cancelled` | `error`) removes every `state >= Invitation.State.X`
317
+ comparison in the dialogs and the CLI's hand-rolled `waitForState`. Under Keyhive the lifecycle
318
+ is unchanged — only what `Identity`/`Space` initiation does behind the flow differs: `share`
319
+ mints an ephemeral invitation key or prekey delegation; `join`/`accept` becomes contact-card
320
+ exchange + delegation (MIGRATION.md §4.6). Keeping the `Flow` state machine in one leaf module
321
+ lets both initiation paths and every dialog/CLI consumer share exactly one shape.
322
+
323
+ ### 3.5 Layering & migration mechanics
324
+
325
+ `@dxos/halo` is **definitions only** (tags, verbs, schemas); implementations are separate
326
+ packages provided as `Layer`s at one composition root — today `plugin-client`, which already
327
+ owns client construction. Plugins import only `@dxos/halo`:
328
+
329
+ ```text
330
+ @dxos/halo Identity.Service / Space.Service (+ Invitation types/flow verbs) (tags + verbs + schemas, NO impl)
331
+ ▲ ▲
332
+ │ │ provide layers at composition:
333
+ │ │
334
+ @dxos/halo-adapter-client ── layerClient(client) (wraps HaloProxy/EchoProxy/InvitationsProxy)
335
+ @dxos/halo-keyhive ── layerKeyhive / layerEdge (future) (Keyhive prototype impl relocates here, MIGRATION.md §A.2)
336
+ ▲
337
+ │
338
+ plugin-client ────── composition root; sole remaining importer of @dxos/client
339
+ ```
340
+
341
+ `layerClient` (in `@dxos/halo-adapter-client`) wraps the existing proxies (`HaloProxy`,
342
+ `EchoProxy` spaces, `InvitationsProxy`) — a mechanical adapter, no behavior change. Later,
343
+ `layerKeyhive` (or the EDGE-endpoint shim) replaces it without touching a single plugin. Because
344
+ `@dxos/halo` carries no implementation, this is a pure layer swap; the Keyhive membership runtime
345
+ will live in a future `@dxos/halo-keyhive`, not in `@dxos/halo`.
346
+
347
+ Suggested sequence:
348
+
349
+ 1. **Define services** in `@dxos/halo` (tags, verbs, event/error schemas — no layers). Inline the
350
+ value types each service needs (`DeviceKind`/`Info`, `Access`/`Member`, `InvitationEvent`,
351
+ `Invitation.Flow`) rather than shipping separate schema modules. Keep the Keyhive membership
352
+ runtime out of `@dxos/halo` (destined for `@dxos/halo-keyhive`).
353
+ 2. **`layerClient`** in a `@dxos/halo-adapter-client` package (keeps `@dxos/halo` free of the
354
+ client dependency), provided by `plugin-client` alongside — then instead of —
355
+ `ClientCapabilities.Client`. **(Implemented — see `@dxos/halo-adapter-client`, with an
356
+ integration suite in `@dxos/halo-e2e`.)**
357
+ 3. **Port operation handlers** (§2.8) — the concentrated mutation sites — from `client.*` to
358
+ service verbs. Plugin control flow (operations) is unchanged.
359
+ 4. **Port hooks**: reimplement `useIdentity`/`useDevices`/`useSpaces`/`useSpace`/`useMembers`/
360
+ `useSpaceInvitations`/`useCredentials` over the services' atoms (in `@dxos/halo-react` or
361
+ `app-toolkit`), then mass-update imports. `AppSpace` converts in the same step.
362
+ 5. **Port dialogs/CLI** off invitation observables onto flow streams; delete `waitForState`
363
+ and the copied Shell components' state plumbing.
364
+ 6. **Stories & testing**: replace `client.halo.createIdentity()` + `client.spaces.create()`
365
+ fixtures (~30 sites) with `Identity.create`/`Space.create` over an in-memory test layer —
366
+ stories stop needing a full `Client` at all, currently the single largest source of
367
+ incidental `@dxos/client` imports.
368
+ 7. **Sweep types**: `Identity`/`Device`/`SpaceMember`/`Credential`/`Invitation` type imports →
369
+ `@dxos/halo` schemas. At this point `@dxos/client` remains only inside `plugin-client`'s
370
+ composition root and `plugin-devtools`.
371
+
372
+ ### 3.6 Gaps & open questions
373
+
374
+ 1. **`Credential` surface** — `RecoveryCredentialsContainer` and the `credential` CLI render
375
+ raw credentials. Transitional verbs (§3.2) cover it, but the rendering will need a schema
376
+ change when credentials become Keyhive membership ops. Acceptable: the container is
377
+ diagnostic UI.
378
+ 2. **`space.internal.db.*`** (`saveStateChanged`, `runMigrations`) — database concerns reached
379
+ through the space proxy; they belong to the ECHO `Database` service track and should be
380
+ excluded from `Space.Service` rather than ported.
381
+ 3. **Presence** — `SpaceMember.presence` conflates membership (HALO) with liveness (MESH).
382
+ Proposal: `Space.members` returns membership only; presence moves to a separate
383
+ stream/service so the Keyhive backend isn't forced to fake it.
384
+ 4. **`PublicKey` vs DID** — consumers compare `identityKey`/`spaceKey` (`PublicKey`). New
385
+ schemas should standardize on DID / `SpaceId` strings (per this package's types); the
386
+ adapter translates during the transition.
387
+ 5. **Enums in operation schemas** — `Schema.Enums(Invitation.Type)` leaks protobuf enums into
388
+ operation contracts (`plugin-space/operations/definitions.ts`); these need parallel Effect
389
+ schema literals and a coordinated cut-over.
390
+ 6. **Shell UI components** — `SpaceMemberList`/`InvitationList`/`AuthCode` from
391
+ `@dxos/shell/react` embed observable-based logic; they should be rebuilt on the new hooks in
392
+ `react-ui-*` (the `MembersContainer` TODO already wants this).
393
+ 7. **Definitions-only invariant** — `@dxos/halo` must not gain a runtime dependency. The Keyhive
394
+ membership prototype (`make`/`layerMemory`) has been removed for this reason and will live in
395
+ `@dxos/halo-keyhive` (§3.5); a lint/dep-cruiser rule should keep `@dxos/halo` from importing
396
+ anything but schema and type primitives so no future layer sneaks back in.