|
| 1 | +# Actor Identity Mapping Plan |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +Tie Automerge actor IDs (SHA-256 hex strings from Google OAuth `sub`) to user screen names by storing an `identities` mapping in the IndexDocument. Add schema versioning so the document format can evolve. In replay, resolve actor hashes to screen names and use a distinct "Me" bounding box colour. |
| 6 | + |
| 7 | +## Current State |
| 8 | + |
| 9 | +- **IndexDocument schema** (`ts-packages/quarto-automerge-schema/src/index.ts`): |
| 10 | + ```typescript |
| 11 | + interface IndexDocument { |
| 12 | + files: Record<string, string>; // path -> docId (exposed as FileEntry[]) |
| 13 | + } |
| 14 | + ``` |
| 15 | +- **Screen names** are stored locally in IndexedDB (`userSettings` store) and used only for presence (ephemeral messaging). They are **not** persisted in the Automerge document. |
| 16 | +- **Actor IDs** are SHA-256 hashes of Google OAuth `sub` claims, computed server-side and returned via `GET /auth/me`. They are set on the Automerge repo and embedded in change history. |
| 17 | +- **Replay** shows `state.actor.slice(0, 8)` (truncated hex) or "Me" if the actor matches `currentActorId`. The "Me" case uses the same CSS styling as other actors. |
| 18 | +- **Waveform** colours are derived from actor hash via `actorColor()` — deterministic hue from first 6 hex chars. |
| 19 | +- **`getUserIdentity()`** (`hub-client/src/services/userSettings.ts`) is async — returns `Promise<UserSettings>` with `.userName`. |
| 20 | +- **`SyncClientCallbacks`** is defined in `ts-packages/quarto-sync-client/src/types.ts`. The `onFilesChange` callback already fires on any IndexDocument change. |
| 21 | + |
| 22 | +## Design |
| 23 | + |
| 24 | +### Schema V1: IndexDocument with identities and version |
| 25 | + |
| 26 | +```typescript |
| 27 | +interface IndexDocument { |
| 28 | + version: number; // schema version (1) |
| 29 | + files: Record<string, string>; // path -> docId (unchanged) |
| 30 | + identities: Record<string, string>; // actorId -> screenName |
| 31 | +} |
| 32 | +``` |
| 33 | + |
| 34 | +- `version` field: integer, currently `1`. Missing `version` means V0 (pre-versioning). |
| 35 | +- `identities` field: maps Automerge actor ID (64-char hex) to the user's current screen name. |
| 36 | +- Both fields are added to the Automerge document, so they sync to all peers and persist in history. |
| 37 | + |
| 38 | +### Migration Strategy |
| 39 | + |
| 40 | +When a client opens a project and the IndexDocument has no `version` field: |
| 41 | +1. Set `version = 1` |
| 42 | +2. Initialize `identities = {}` |
| 43 | +3. Write the current user's identity mapping |
| 44 | + |
| 45 | +This is safe because Automerge merges concurrent changes — if two clients migrate simultaneously, both additions merge cleanly (they're setting non-conflicting keys). |
| 46 | + |
| 47 | +**Automerge initialization note**: Inside a `handle.change()` callback, assigning `doc.identities = {}` creates an Automerge Map proxy. This is the correct pattern — Automerge intercepts the assignment. However, **do not** create a plain JS object outside the callback and assign it; always assign inside `change()`. |
| 48 | + |
| 49 | +### Identity Sync on Connect |
| 50 | + |
| 51 | +Every time a user opens a project (in `connect()` or `createNewProject()`): |
| 52 | +1. Read `doc.identities[actorId]` |
| 53 | +2. Compare to the user's current screen name |
| 54 | +3. If missing or different, update `doc.identities[actorId] = screenName` |
| 55 | + |
| 56 | +This ensures name changes are propagated to the document. |
| 57 | + |
| 58 | +### Screen Name Loading |
| 59 | + |
| 60 | +Screen name is loaded from `getUserIdentity()` (async, IndexedDB) in `App.tsx`. It must be available **before** any connect/create call. The loading sequence: |
| 61 | + |
| 62 | +1. Auth resolves (`useAuth()` — provides `actorId` and OIDC `name`) |
| 63 | +2. User identity resolves (`getUserIdentity()` — provides `userName`) |
| 64 | +3. **OIDC name defaulting**: If auth provides a display name and the stored name is still an auto-generated anonymous one (starts with "Anonymous "), the screen name is automatically upgraded to the OIDC display name and persisted to IndexedDB. This happens once on first login; subsequent visits use the persisted value. |
| 65 | +4. Both are available when project selector renders |
| 66 | +5. `screenName` is passed alongside `actorId` through `connect()` and `createNewProject()` |
| 67 | + |
| 68 | +For non-auth instances (`AUTH_ENABLED` is false), screen name loads immediately from IndexedDB without waiting for auth, preserving the anonymous name default. |
| 69 | + |
| 70 | +### Screen Name Reset |
| 71 | + |
| 72 | +The ProjectSelector "Your Identity" section has a reset button: |
| 73 | +- **With auth**: "Reset Name" — resets screen name to the OIDC display name (`auth.name`) |
| 74 | +- **Without auth**: "Randomize Name" — generates a new anonymous name (original behavior) |
| 75 | + |
| 76 | +### Identity Change Notification |
| 77 | + |
| 78 | +A separate `onIdentitiesChange` callback keeps concerns cleanly separated — file changes and identity changes are logically independent events (e.g. a new user connecting updates identities but not files). The `onFilesChange` signature stays untouched. |
| 79 | + |
| 80 | +```typescript |
| 81 | +onIdentitiesChange?: (identities: Record<string, string>) => void |
| 82 | +``` |
| 83 | + |
| 84 | +In the `indexHandle.on('change', ...)` handler, the sync client diffs both `files` and `identities` from the previous state, firing each callback only when its data actually changes. This means consumers subscribe only to what they care about. |
| 85 | + |
| 86 | +### Stale Identities |
| 87 | + |
| 88 | +Identities persist in the Automerge document forever. This is intentional — the document history is immutable, and stale identity mappings are harmless (they only map actor ID to screen name). Old contributors' names remain resolvable in replay, which is a feature. |
| 89 | + |
| 90 | +### Replay Changes |
| 91 | + |
| 92 | +- **Actor label**: Instead of `state.actor.slice(0, 8)`, look up `identities[state.actor]` from the IndexDocument. Fall back to truncated hex if not found. |
| 93 | +- **"Me" indicator**: Use a visually distinct bounding box (different border colour/background) instead of replacing the name with "Me". Display both the screen name AND the "Me" indicator so the user knows their own name as others see it. |
| 94 | +- **Waveform**: `actorColor()` stays hash-based (deterministic, no lookup needed). No change. |
| 95 | + |
| 96 | +## Work Items |
| 97 | + |
| 98 | +### Phase 1: Tests for Schema Changes |
| 99 | + |
| 100 | +- [x] **1.1** Write unit tests for `IndexDocument` migration in `ts-packages/quarto-automerge-schema`: |
| 101 | + - V0 doc (no version, no identities) -> migrates to V1 |
| 102 | + - V1 doc (already has version) -> no-op |
| 103 | + - `setIdentity` adds new, overwrites changed, leaves unchanged |
| 104 | +- [x] **1.2** Run tests — verify they pass (8 tests) |
| 105 | + |
| 106 | +### Phase 2: Schema Implementation |
| 107 | + |
| 108 | +- [x] **2.1** Update `IndexDocument` type in `ts-packages/quarto-automerge-schema/src/index.ts`: |
| 109 | + - Add `version?: number` and `identities?: Record<string, string>` fields |
| 110 | + - Make them optional since V0 docs won't have them |
| 111 | + - Export a `CURRENT_SCHEMA_VERSION = 1` constant |
| 112 | +- [x] **2.2** Add a migration helper in `ts-packages/quarto-automerge-schema/src/index.ts`: |
| 113 | + - `migrateIndexDocument(doc: IndexDocument): boolean` — returns true if changes were made |
| 114 | + - Checks for missing `version`, sets to 1, initializes `identities` if absent |
| 115 | +- [x] **2.3** Add an identity update helper: |
| 116 | + - `setIdentity(doc: IndexDocument, actorId: string, screenName: string): boolean` — returns true if identity was added/updated |
| 117 | +- [x] **2.4** Run Phase 1 tests — verify they pass |
| 118 | + |
| 119 | +### Phase 3: Tests for Sync Client Integration |
| 120 | + |
| 121 | +- [ ] **3.1** Write unit tests for sync client identity flow in `ts-packages/quarto-sync-client`: |
| 122 | + - `connect()` with actorId + screenName writes identity to doc |
| 123 | + - `createNewProject()` initializes with version and identity |
| 124 | + - Screen name update overwrites stale value |
| 125 | + - `onIdentitiesChange` fires when identities change but not when only files change |
| 126 | +- [ ] **3.2** Run tests — verify they fail |
| 127 | + |
| 128 | +### Phase 4: Sync Client Implementation |
| 129 | + |
| 130 | +- [x] **4.1** Add `onIdentitiesChange` callback to `SyncClientCallbacks` in `ts-packages/quarto-sync-client/src/types.ts`: |
| 131 | + - `onIdentitiesChange?: (identities: Record<string, string>) => void` |
| 132 | + - `onFilesChange` signature stays unchanged |
| 133 | +- [x] **4.2** Update `connect()` in `ts-packages/quarto-sync-client/src/client.ts`: |
| 134 | + - Add `screenName?: string` parameter (after `actorId`) |
| 135 | + - After loading the IndexDocument, call `indexHandle.change()` to migrate schema if needed |
| 136 | + - Write `identities[actorId] = screenName` if it differs from what's stored |
| 137 | + - In the `indexHandle.on('change', ...)` handler, diff identities from previous state and call `onIdentitiesChange` only when identities actually changed |
| 138 | + - Fire `onIdentitiesChange` on initial load with current identities |
| 139 | +- [x] **4.3** Update `createNewProject()` in the same file: |
| 140 | + - Add `screenName?: string` parameter (after `actorId`) |
| 141 | + - When initializing the IndexDocument, set `version: 1`, `identities: {}`, and write the creator's identity |
| 142 | + - Fire `onIdentitiesChange` with initial identities |
| 143 | +- [ ] **4.4** Run Phase 3 tests — verify they pass |
| 144 | + |
| 145 | +### Phase 5: Tests for Hub-Client UI |
| 146 | + |
| 147 | +- [x] **5.1** Write tests for `ReplayDrawer`: |
| 148 | + - Renders screen name instead of hex when identity available |
| 149 | + - Renders truncated hex when identity not available |
| 150 | + - Applies `--me` CSS class for current actor |
| 151 | +- [x] **5.2** Run tests — verify they pass (29 tests) |
| 152 | + |
| 153 | +### Phase 6: Hub-Client Implementation |
| 154 | + |
| 155 | +- [x] **6.1** Update `automergeSync.ts` (`hub-client/src/services/automergeSync.ts`): |
| 156 | + - Add `screenName` parameter to `connect()` and `createNewProject()` |
| 157 | + - Forward to sync client |
| 158 | +- [x] **6.2** Update `App.tsx` (`hub-client/src/App.tsx`): |
| 159 | + - Add `screenName` state, loaded via `getUserIdentity().userName` in a `useEffect` (runs after auth resolves, or unconditionally when auth is disabled) |
| 160 | + - Pass `screenName` to all `connectAndLoadContents()` and `createNewProject()` calls |
| 161 | + - Store `identities` map in React state via `onIdentitiesChange` callback |
| 162 | + - Pass `identities` to `Editor` |
| 163 | +- [x] **6.3** Update `Editor.tsx` (`hub-client/src/components/Editor.tsx`): |
| 164 | + - Accept `identities` prop (`Record<string, string>`) |
| 165 | + - Pass it through to `ReplayDrawer` |
| 166 | +- [x] **6.4** Update `ReplayDrawer` (`hub-client/src/components/ReplayDrawer.tsx`): |
| 167 | + - Add `identities` to `Props` interface |
| 168 | + - Update actor label rendering: |
| 169 | + - Resolve `state.actor` to screen name via `identities[state.actor]` |
| 170 | + - Fall back to `state.actor.slice(0, 8)` if no identity found |
| 171 | + - For "Me": show `screenName (Me)` alongside the resolved name |
| 172 | + - Add `replay-drawer__actor--me` CSS class with distinct border/background colour |
| 173 | +- [x] **6.5** Run Phase 5 tests — verify they pass |
| 174 | + |
| 175 | +### Phase 7: Full Verification |
| 176 | + |
| 177 | +- [x] **7.1** `cargo build --workspace` — passes |
| 178 | +- [x] **7.2** Run hub-client tests: 383 tests pass (including schema + ReplayDrawer) |
| 179 | +- [x] **7.3** Run hub-client build: passes |
| 180 | +- [x] **7.4** e2e fixtures create V0 IndexDocuments; migration handles them at connect time — no changes needed |
| 181 | + |
| 182 | +## Files Modified |
| 183 | + |
| 184 | +### `ts-packages/quarto-automerge-schema/src/index.ts` |
| 185 | +- `IndexDocument` type: add `version?` and `identities?` |
| 186 | +- New exports: `CURRENT_SCHEMA_VERSION`, `migrateIndexDocument()`, `setIdentity()` |
| 187 | + |
| 188 | +### `ts-packages/quarto-sync-client/src/types.ts` |
| 189 | +- Add `onIdentitiesChange?: (identities: Record<string, string>) => void` callback |
| 190 | +- `onFilesChange` signature unchanged |
| 191 | + |
| 192 | +### `ts-packages/quarto-sync-client/src/client.ts` |
| 193 | +- `connect()`: add `screenName` param, call migration + identity sync, fire `onIdentitiesChange` |
| 194 | +- `createNewProject()`: add `screenName` param, initialize V1 schema with identity, fire `onIdentitiesChange` |
| 195 | +- In change handler, diff identities and fire `onIdentitiesChange` only when identities changed |
| 196 | + |
| 197 | +### `hub-client/src/services/automergeSync.ts` |
| 198 | +- `connect()`, `createNewProject()`: add `screenName` passthrough |
| 199 | + |
| 200 | +### `hub-client/src/App.tsx` |
| 201 | +- New state: `screenName` (loaded from `getUserIdentity()`, auto-upgraded from OIDC name on first login) |
| 202 | +- Pass `screenName` to connect/create calls (with `screenName` in dependency arrays) |
| 203 | +- Store + update `identities` via `onIdentitiesChange` callback |
| 204 | +- Pass `identities` to `Editor` |
| 205 | +- Pass `authName` to `ProjectSelector` for screen name reset |
| 206 | + |
| 207 | +### `hub-client/src/components/Editor.tsx` |
| 208 | +- Accept + forward `identities` prop to `ReplayDrawer` |
| 209 | + |
| 210 | +### `hub-client/src/components/ProjectSelector.tsx` |
| 211 | +- Accept `authName` prop (OIDC display name) |
| 212 | +- "Reset Name" button: resets to OIDC name when available, randomizes when not |
| 213 | + |
| 214 | +### `hub-client/src/components/ReplayDrawer.tsx` |
| 215 | +- Accept `identities` prop |
| 216 | +- Resolve actor to screen name |
| 217 | +- Add `--me` CSS class with distinct styling |
| 218 | + |
| 219 | +### `hub-client/src/components/ReplayDrawer.css` |
| 220 | +- New `.replay-drawer__actor--me` class |
| 221 | + |
| 222 | +### Test files (new or updated) |
| 223 | +- `ts-packages/quarto-automerge-schema/src/__tests__/migration.test.ts` |
| 224 | +- `ts-packages/quarto-sync-client/src/__tests__/identity.test.ts` |
| 225 | +- `hub-client/src/components/ReplayDrawer.test.tsx` (update existing) |
| 226 | + |
| 227 | +### Possibly updated |
| 228 | +- `hub-client/e2e/scripts/regenerate-fixtures.ts` (if fixtures include IndexDocument) |
| 229 | + |
| 230 | +## Key Decisions |
| 231 | + |
| 232 | +1. **Optional fields**: `version` and `identities` are optional on the type to handle V0 docs. Migration happens at connect time. |
| 233 | + |
| 234 | +2. **Screen name source**: The screen name stored in IndexedDB is the authoritative source, persisted in the Automerge document's `identities` map. On first login, if the stored name is still an auto-generated "Anonymous ..." name, it is automatically upgraded to the OIDC display name (`auth.name`) and persisted to IndexedDB. Users can further customize it; a "Reset Name" button restores it to the OIDC display name. For non-auth instances, anonymous names are used as the default. |
| 235 | + |
| 236 | +3. **Screen name loading**: Loaded in `App.tsx` via `useEffect` after auth. Available before any project connection. Passed explicitly through the call chain — not fetched inside the sync client (which has no IndexedDB access). |
| 237 | + |
| 238 | +4. **No colour in identities**: Cursor colours are per-session (presence) and per-hash (replay waveform). Storing them in the document would create conflicts. Keep colours derived locally. |
| 239 | + |
| 240 | +5. **Concurrent migration safety**: Automerge's CRDT semantics mean two clients setting `version = 1` and `identities = {}` simultaneously will merge correctly. Identity writes to different keys merge without conflict. |
| 241 | + |
| 242 | +6. **"Me" indication**: Use a different CSS class (`--me`) with a distinct border colour rather than replacing the name. This way the user sees their own name as others see it while still knowing which edits are theirs. |
| 243 | + |
| 244 | +7. **Separate `onIdentitiesChange` callback**: Files and identities are logically independent events (a user connecting changes identities but not files). A dedicated callback keeps `onFilesChange` untouched, lets consumers subscribe only to what they need, and fires only when identities actually change. |
| 245 | + |
| 246 | +8. **Stale identities are fine**: Identity mappings persist forever in the Automerge document. The history is immutable, and keeping old mappings means replay can always resolve actor names — even for contributors who have left. |
| 247 | + |
| 248 | +9. **Scrubber tooltip**: The waveform scrubber tooltip only shows timestamps (not actor info), so no changes needed there. |
0 commit comments