docs: native-apps.md reflects the merged stack β statuses, not branches
The status header narrated six in-flight branches (all deleted after the 2026-07-23 squash-merges); it now records what shipped and points phase 6 as the one open item. Protocol-table rows normalize to "shipped", the phases list becomes the shipped record (with the sandbox-projection relay added to phase 6 β member apps' feed narration, deferred deliberately in #1317), and the "where native is today" map is banner-marked historical: its root-cause table explains WHY the architecture looks as it does, but every symptom in it is fixed on main except the vault passkey row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+38
−57
docs/native-apps.md+38 −57
diff --git a/docs/native-apps.md b/docs/native-apps.md| index 3d15db7..5038f33 100644 |
| --- a/docs/native-apps.md |
| +++ b/docs/native-apps.md |
| @@ -1,44 +1,17 @@ |
| # π€ Native apps Γ the app platform: apps as first-class citizens |
| -> Status: **DESIGN + PHASES 0β5 (2026-07-22)** β phase 5 (branch |
| -> `claude/native-apps-projection`) ships projection queries: app events |
| -> AppChatProjection can't answer (the sync, vector-pinned fast path for |
| -> schema apps + installs) stash during the fold and query the thread's |
| -> command host (`projectEvent` β the web's own chatMessage/groupFeedMessage), |
| -> folding the answered lines in as the same synthesized bubbles. Open feeds |
| -> only (NSE/background sync keep the fast path); member apps stay silent |
| -> natively (their hooks run only in the sandbox). Phase 4 (branch |
| -> `claude/native-apps-space`) ships space routing + the space band: the |
| -> embed space-thread bail lifts when the native shell seeded the instance |
| -> key (space-installed apps' commands β /gif β route in the command host), |
| -> a `space:true` embed seed renders a space app's surface through the web's |
| -> own openSpaceApp path, and both platforms grow a space band (Mac sidebar |
| -> rows / iOS chips) from SpaceAppsStore Γ the registry β the manifest gains |
| -> `surface` (has a render) so command-only apps get no row. Phase 3 (branch |
| -> `claude/native-apps-commands`) ships commands everywhere: the command host |
| -> moves to LChatCore and iOS gains it (panel + notices in the native |
| -> composer), both platforms forward every non-floor slash command |
| -> (`CommandRouting` over live `commandItems()` rows β bot-answered rides to |
| -> the conversation, framework-owned forwards), the "/" typeahead consumes the |
| -> per-thread rows, and the `needKey`/`needAppThread` bridge lets a command |
| -> typed in one thread emit into another (native answers with one narrow |
| -> thread key / an open-or-created app thread β never the private key). |
| -> Phase 2 (branch |
| -> `claude/native-apps-registry`) ships `AppRegistry`: the bundled seed |
| -> catalogue generated from the web framework (`tools/gen-app-catalog.mjs`, |
| -> drift-gated by `test/appcatalog-seed.test.mjs`), live `appCatalog()` pulls |
| -> from ready embeds (+`{catalogChanged}` re-pulls), and every lookup table |
| -> collapsed into registry reads: pickers (incl. a NEW iOS personal-apps |
| -> section), surface routing (`registry.presentation`), the install |
| -> read-filter, and per-app titles/glyphs/blurbs. This branch ships the design, |
| -> the web side of the app-host protocol (`appCatalog()` / `commandItems()` / |
| -> `catalogChanged`, pinned by `test/appcatalog.test.mjs`), and phase 1: |
| -> `SpaceApps`/`SpaceAppsStore` in LChatCore (space-thread fetch + `open_key` |
| -> unwrap, the `spaceapps` fold pinned by `SpaceAppsVectorTests`, live-frame |
| -> wiring on both platforms, per-origin Keychain persistence for installs and |
| -> the `AppManifest` catalogue). Phases 2+ land as their own small PRs, iOS |
| -> first per CLAUDE.md. This note is the handoff brief for all of them β the |
| -> same role `docs/native-chat.md` played for the native feed. |
| +> Status: **SHIPPED β phases 0β5 + the voice surfaces merged to `main` |
| +> 2026-07-23** (#1256, #1314β#1318; field fixes from live evaluation rode the |
| +> same PRs). What runs today: the app-host protocol below (all rows marked |
| +> shipped), `AppRegistry` (bundled seed from `tools/gen-app-catalog.mjs`, |
| +> drift-gated by `test/appcatalog-seed.test.mjs`, live `appCatalog()` pulls), |
| +> `SpaceApps`/`SpaceAppsStore` (installs survive relaunch), the platform-shared |
| +> command host on both platforms (full forwarding, per-thread typeahead rows, |
| +> the `needKey`/`needAppThread` bridge), space routing + the space band, |
| +> projection/preview queries (feeds and sidebars narrate any app's events), |
| +> and the voice contract (`web/static/apps/README.md Β§ Voice`). Only phase 6 |
| +> ("make it good", below) remains open. This note is the standing design |
| +> reference β the same role `docs/native-chat.md` plays for the native feed. |
| ## The position (accepted, load-bearing) |
| @@ -144,7 +117,12 @@ the E2EE-shaped form the one rule demands: |
| The whole plan is client work. The server's part β blobs, prods, schedules, |
| state slots, the space thread β is finished and in production. |
| -## Where native is today (the honest map) |
| +## Where native was before this work (the honest map, kept as the record) |
| + |
| +> π€ Historical: this section described native as of 2026-07-22, and is what |
| +> the phases below were designed against. Every symptom in it is fixed on |
| +> `main` except the vault passkey row (phase 6). Kept because the root-cause |
| +> table is the best explanation of WHY the architecture looks the way it does. |
| Three parallel app subsystems grew independently in `ios/`: |
| @@ -224,16 +202,16 @@ runs any command, and already reports panels/replies/errors. |
| | direction | call / post | meaning | status | |
| |---|---|---|---| |
| -| native β js | `appCatalog()` β `[manifestβ¦]` | the full registered-app catalogue (all tiers) | **phase 0 (this branch)** | |
| -| native β js | `commandItems()` β `[rowβ¦]` | `commandCatalog(threadId)` for the embed's thread β the typeahead/`/help` source | **phase 0 (this branch)** | |
| +| native β js | `appCatalog()` β `[manifestβ¦]` | the full registered-app catalogue (all tiers) | shipped | |
| +| native β js | `commandItems()` β `[rowβ¦]` | `commandCatalog(threadId)` for the embed's thread β the typeahead/`/help` source | shipped | |
| | native β js | `runCommand(text)` β handled? | run any slash command; replies/panels post back | exists | |
| | native β js | `dismissPanel()` | close the open panel | exists | |
| -| js β native | `{catalogChanged: kind}` | the app set changed (`apps_changed`); re-pull `appCatalog`, recreate stale webviews | **phase 0 (this branch)** | |
| +| js β native | `{catalogChanged: kind}` | the app set changed (`apps_changed`); re-pull `appCatalog`, recreate stale webviews | shipped | |
| | js β native | `{commandHostReady}`, `{panel}`, `{panelHeight}`, `{notice}`, `{composerInsert}`, `{error}` | command-host lifecycle | exists | |
| -| js β native | `{needKey: threadId}` β native calls `__lchatProvideKey(threadId, keyB64)` | cross-thread routing: a personal command typed in a group must emit into the personal app-thread, whose key the single-key embed doesn't hold. Native unwraps (it can only unwrap threads the user is in) and provides. | **phase 3 (shipped)** | |
| -| js β native | `{needAppThread: {app, title}}` β native calls `__lchatProvideAppThread(app, threadId, keyB64)` | the appβthread resolution itself: the embed can't decrypt other threads' name_blobs, so resolving there would mint a duplicate app thread. Native opens-or-creates (`openOrCreateAppThread`) and answers with the id + key. | **phase 3 (shipped)** | |
| -| native β js | `previewEvent(event, you)` β `{text} \| null` | the sidebar line for one app event β the app's own `preview(e, {you})` hook, defaulting in the engine to the actor-prefixed chat projection (the voice chain: notice β preview β chat projection β silent). `you` = the actor is the reader ("You addedβ¦"). | **voice PR (shipped)** | |
| -| native β js | `projectEvent(event, group)` β `{text, action, title} \| null` | chat projection of an app event, for the native feed (companion Chat views, group narration) β the web's `chatMessage`/`groupFeedMessage` answered by the engine that owns them. Ownership resolves in the engine via `appForType` (native never prefix-guesses β `webhooks.*` belongs to webhook-monitor); `title` rides along so native names the bubble. | **phase 5 (shipped)** | |
| +| js β native | `{needKey: threadId}` β native calls `__lchatProvideKey(threadId, keyB64)` | cross-thread routing: a personal command typed in a group must emit into the personal app-thread, whose key the single-key embed doesn't hold. Native unwraps (it can only unwrap threads the user is in) and provides. | shipped | |
| +| js β native | `{needAppThread: {app, title}}` β native calls `__lchatProvideAppThread(app, threadId, keyB64)` | the appβthread resolution itself: the embed can't decrypt other threads' name_blobs, so resolving there would mint a duplicate app thread. Native opens-or-creates (`openOrCreateAppThread`) and answers with the id + key. | shipped | |
| +| native β js | `previewEvent(event, you)` β `{text} \| null` | the sidebar line for one app event β the app's own `preview(e, {you})` hook, defaulting in the engine to the actor-prefixed chat projection (the voice chain: notice β preview β chat projection β silent). `you` = the actor is the reader ("You addedβ¦"). | shipped | |
| +| native β js | `projectEvent(event, group)` β `{text, action, title} \| null` | chat projection of an app event, for the native feed (companion Chat views, group narration) β the web's `chatMessage`/`groupFeedMessage` answered by the engine that owns them. Ownership resolves in the engine via `appForType` (native never prefix-guesses β `webhooks.*` belongs to webhook-monitor); `title` rides along so native names the bubble. | shipped | |
| Trust posture: the app host is a privileged surface *by job description* β it |
| is the command engine, so its reach is the union of the member's app threads, |
| @@ -280,36 +258,39 @@ apps' commands route correctly inside the host too (phase 4). |
| narration come from the registry + projection queries, one design on both |
| platforms (Mac keeps Split; iOS keeps toggle). |
| -## Phases (small PRs; each stands alone; iOS first where UI diverges) |
| +## Phases (all shipped 2026-07-23 except phase 6; each landed as its own PR) |
| -- **Phase 0 β protocol groundwork (this branch, web-side, testable in Node).** |
| +- **Phase 0 β protocol groundwork (web-side, testable in Node). SHIPPED.** |
| `appCatalogReport()` in `apps.js`; `appCatalog()` / `commandItems()` on the |
| embed bridge; `{catalogChanged}` post on `apps_changed` in embeds; |
| `test/appcatalog.test.mjs` pins the shapes. |
| -- **Phase 1 β installs survive: `SpaceAppsStore` + registry persistence.** |
| +- **Phase 1 β installs survive: `SpaceAppsStore` + registry persistence. SHIPPED.** |
| LChatCore: space-thread fetch/unwrap, `spaceapps` fold (vectors exist: |
| `spaceapps.json`), `space_thread`/`apps_changed` in `ChatSocket`, disk |
| persistence for catalogue + installs. Acceptance: kill + relaunch β a |
| space's installed apps (vault included) are still listed. |
| -- **Phase 2 β one registry, registry-driven routing.** Collapse the lookup |
| +- **Phase 2 β one registry, registry-driven routing. SHIPPED.** Collapse the lookup |
| tables into `AppRegistry`; route surfaces and pickers from it. Acceptance: |
| Admin gets Chat|View on Mac and opens as the app on iOS; the board is |
| addable to a group and loads; unknown/custom apps appear with glyph+title. |
| -- **Phase 3 β commands everywhere.** iOS gains the command host; both |
| +- **Phase 3 β commands everywhere. SHIPPED.** iOS gains the command host; both |
| platforms forward *every* non-floor slash command to it; typeahead rows |
| from `commandItems()`; `needKey` bridge for cross-thread routing. |
| Acceptance: `/todo buy milk` works from any chat on both platforms; |
| `/emoji`/`/gif` stop dead-posting on iOS; a custom app's command appears in |
| the typeahead with its desc/args. |
| -- **Phase 4 β space routing in the host.** Lift the embed space-thread bail |
| +- **Phase 4 β space routing in the host. SHIPPED.** Lift the embed space-thread bail |
| when the instance key is present; space band UI. Acceptance: `/gif` (space |
| app) works natively where installed. |
| -- **Phase 5 β projection queries.** Native feeds narrate app events for apps |
| +- **Phase 5 β projection queries. SHIPPED.** Native feeds narrate app events for apps |
| without native projections (`projectEvent`); `AppChatProjection` becomes the |
| fast path for schema apps, same outputs, vector-pinned. |
| -- **Phase 6 β make it good.** More schemas; native panel rendering; the vault |
| - passkey/WebAuthn host bridge; per-instance host consolidation; native |
| - snapshot writing direct from `SchemaAppStore` if the host path isn't enough. |
| +- **Phase 6 β make it good. OPEN.** More schemas; native panel rendering; the |
| + vault passkey/WebAuthn host bridge; per-instance host consolidation; native |
| + snapshot writing direct from `SchemaAppStore` if the host path isn't enough; |
| + the sandbox-projection relay, so MEMBER apps' events narrate in native feeds |
| + too (their `chatMessage` runs only inside the `/app-sandbox` iframe, so |
| + `projectEvent` deliberately answers null for them today). |
| ## Deliberately out of scope |