| 1 | # π€ Member apps: LLM-built or member-written, member-deployed, sandboxed |
| 2 | |
| 3 | Members get an app two ways: ask a bot in plain chat ("build me a habit |
| 4 | tracker"), or write the code themselves and deploy it from **App Studio**. |
| 5 | Either way it lands in the Apps menu a moment later. This document is the |
| 6 | design: what runs where, what the server can see, and why untrusted code can't |
| 7 | touch anything that matters. The authoring rules live in |
| 8 | `docs/building-member-apps.md` (served at `/api/member-app-guide`); the |
| 9 | mini-app platform itself in `web/static/apps/README.md`. |
| 10 | |
| 11 | ## Two trust tiers |
| 12 | |
| 13 | | | custom apps (existing) | member apps (this doc) | |
| 14 | |---|---|---| |
| 15 | | who ships it | the instance **admin** | any **member** (usually via a bot) | |
| 16 | | what it is | CODE the admin vetted | USER CONTENT | |
| 17 | | stored as | plaintext JS (`custom_apps`) | instance-key-encrypted blob (`member_apps`) | |
| 18 | | served as | an executable ES module | never β the server can't even read it | |
| 19 | | runs | in the page, full trust | in an opaque-origin sandboxed iframe | |
| 20 | | install gate | admin token | member token; author-only overwrite | |
| 21 | | moderation | admin delete | author or admin delete | |
| 22 | |
| 23 | The existing tier is unchanged. The new tier exists because "anyone can add |
| 24 | code" and "code runs with access to everything a device can decrypt" cannot |
| 25 | coexist β so member apps get a cage instead of a vetting step. |
| 26 | |
| 27 | ## The loop, end to end |
| 28 | |
| 29 | 1. **Ask.** A member tells a model-enabled bot what they want. The bot's |
| 30 | system prompt teaches it to interview briefly, then call its `create_app` |
| 31 | tool with a name/title/glyph/spec (`internal/bot/brain.go`). |
| 32 | 2. **Build.** The sidecar runs a dedicated generation call against the |
| 33 | instance's own authoring guide (fetched from `/api/member-app-guide`, so |
| 34 | bot and server can never disagree), then statically validates the module β |
| 35 | allowed imports only, no network/storage APIs, one `defineApp`, namespaced |
| 36 | event types β and regenerates on failure (`internal/bot/appbuild.go`). |
| 37 | This validation is a *quality* gate; the sandbox below is the security |
| 38 | boundary. |
| 39 | 3. **Deploy.** The bot β an ordinary E2EE member holding its own wrapped copy |
| 40 | of the instance key β encrypts `{ source, manifest }` under that key and |
| 41 | `PUT`s the blob to `/api/member-apps/{id}` with its member token |
| 42 | (`memberapps.go`). The id is random; the server learns nothing but |
| 43 | (id, author, size, time). `apps_changed` broadcasts and clients reload. |
| 44 | 4. **Launch.** At boot the client fetches the list, decrypts each blob with |
| 45 | the instance key, validates the manifest, and registers a **proxy app** |
| 46 | through the ordinary `defineApp` (`web/static/sandbox.js`) β so the app |
| 47 | appears in New chat's Apps list, gets its own personal thread, folds |
| 48 | events, and snapshots exactly like a built-in. Opening it mounts the |
| 49 | sandbox frame; the member sees their custom UI full-pane in the shell. |
| 50 | |
| 51 | ## Fixing an existing app: the bot's eyes |
| 52 | |
| 53 | Building is only half the loop β members come back with "the done button does |
| 54 | nothing, fix it". The server can't help (it holds ciphertext), so the bot's |
| 55 | sight is client-side, built from the same instance key it deploys with |
| 56 | (`internal/bot/appbuild.go`): |
| 57 | |
| 58 | - **Discovery.** Every model reply carries a per-sender system note listing |
| 59 | the apps *that member owns* β exact `app_id`, title, name, version, builder |
| 60 | β decrypted from the deployed list (briefly cached; the sidecar's own |
| 61 | deploys invalidate it). Without this, the model only ever knew an id that |
| 62 | happened to appear in the recent conversation, and "fix my habit tracker" |
| 63 | from a fresh chat silently became a blind second build. |
| 64 | - **Reading.** A `read_app_source` tool returns one app's manifest + current |
| 65 | source (owner-gated, exactly like updating), run in a short bounded tool |
| 66 | loop inside the reply β so the bot can diagnose a bug or explain the app's |
| 67 | behaviour before (or without) rebuilding. |
| 68 | - **Updating.** `create_app` with the `app_id` from the note; the build call |
| 69 | regenerates from the app's current source, so the fix is an edit, not a |
| 70 | from-scratch rewrite. The deploy verifies and announces the new version, |
| 71 | and the old one stays in App Studio's History to revert to. |
| 72 | |
| 73 | All three stay inside the ownership gate: the note lists only the sender's |
| 74 | apps, reads and updates check the manifest's `owner` against the requester, |
| 75 | and other members' apps are invisible and untouchable. |
| 76 | |
| 77 | ## The sandbox |
| 78 | |
| 79 | The proxy's `render` mounts: |
| 80 | |
| 81 | ``` |
| 82 | <iframe src="/app-sandbox" sandbox="allow-scripts"> |
| 83 | ``` |
| 84 | |
| 85 | - **Opaque origin.** `sandbox` without `allow-same-origin` gives the document |
| 86 | a null origin: no cookies, no localStorage/IndexedDB of the instance |
| 87 | origin (where the session token and private key live), no DOM access to |
| 88 | the parent page, no popups, no top navigation. |
| 89 | - **Own CSP, not the page's.** `/app-sandbox` is a real page (memberapps.go) |
| 90 | served with `default-src 'none'; connect-src 'none'; script-src |
| 91 | '<sha256>' 'unsafe-eval'; img-src data: blob:; style-src 'unsafe-inline'; |
| 92 | frame-src 'none'; form-action 'none'`. No fetch/XHR/WebSocket/beacon to |
| 93 | anywhere β including our own server. The script-src is a **hash of the one |
| 94 | inline runtime, deliberately NOT a nonce**: a nonce is readable by |
| 95 | same-document JS (`document.querySelector('script').nonce`), and a nonced |
| 96 | `<script src>` bypasses the host allow-list β so hostile app code could |
| 97 | read the nonce and pull remote code / exfiltrate despite `connect-src |
| 98 | 'none'`. A hash authorises exactly that one script and nothing the app can |
| 99 | mint, so every `<script src>` (and every other inline script) is refused. |
| 100 | (A `srcdoc` frame would have *inherited* the chat page's CSP; a real page |
| 101 | carries its own.) |
| 102 | - **One trusted script.** The page inlines `web/static/sandbox-frame.js` |
| 103 | (its bytes are what the CSP hash covers). It receives `{ source, manifest, |
| 104 | seed, me, theme }` over the bridge, rewrites the module's imports into an |
| 105 | SDK shim (`transformModuleSource`), evals it (`'unsafe-eval'` is scoped to |
| 106 | this caged document), folds events with the app's real reducer, renders |
| 107 | into the frame's own DOM, and reports state back. |
| 108 | - **The bridge is the only doorway.** A `MessageChannel` the host establishes |
| 109 | on the iframe's **`load` event** β an unforgeable signal that fires on every |
| 110 | genuine (re)load, unlike a `postMessage` "ready" which app code could spoof |
| 111 | to hijack the port. The frame verifies init comes from `window.parent` with |
| 112 | a real (non-null) origin, so one sandboxed app can't initialise a sibling. |
| 113 | Everything a frame sends is validated host-side (`web/static/sandbox.js`): |
| 114 | - **emit/schedule/ephemeral** β the event must be one of the app's own |
| 115 | manifest types (all prefixed `<name>.`), deep-cleaned to plain JSON (no |
| 116 | `__proto__`/`constructor` keys, depth- and size-capped), transport |
| 117 | fields (`name`/`ts`/`uid`) stripped, `notice` clamped to plain strings |
| 118 | with action chips removed, and rate-limited. A member app can never |
| 119 | spoof `chat.*`, another app's events, or a plain chat message. |
| 120 | - **state** β size-capped, JSON-normalized, installed only through |
| 121 | `installMemberAppState` (apps.js), which is gated on the proxy marker. |
| 122 | |
| 123 | What's inside the frame when it runs: the app's source, **its own thread's** |
| 124 | decrypted events and folded state, the member's uid/screen name, and the |
| 125 | theme variables. What is never inside: keys, tokens, other threads, the |
| 126 | roster, message content from anywhere else. The worst a malicious member app |
| 127 | can do is lie about or destroy *its own* state β the same power its reducer |
| 128 | always had. |
| 129 | |
| 130 | Known residual channels, accepted and bounded: the frame can navigate itself |
| 131 | back to `/app-sandbox` (a self-reload β it just resets its own cage, gaining |
| 132 | nothing); cross-origin navigation and remote subresource loads are blocked by |
| 133 | the parent's `frame-src 'self'` and the frame's own hash-pinned `script-src` + |
| 134 | `connect-src 'none'` + `img-src data: blob:`. `postMessage` from app code is |
| 135 | validated away (init rides the load event, not a message). Nothing touches the |
| 136 | E2EE core: keys, tokens, other threads, and the parent DOM never enter the |
| 137 | frame, so the worst a hostile app does is corrupt or spoof its *own* pane. |
| 138 | |
| 139 | ## State: `{ v, vUpTo, pend }` |
| 140 | |
| 141 | The host never runs the untrusted reducer, but the framework's snapshot |
| 142 | machinery needs *some* fold. The proxy's fold is a trusted generic queue: |
| 143 | |
| 144 | - every wire event of the app's types appends `{ e, meta }` to `pend` |
| 145 | (bounded; overflow drops oldest and flags `lost`); |
| 146 | - the frame folds those events with the real reducer and reports |
| 147 | `{ state, upTo }`; the host swaps that in as `v`/`vUpTo` and trims `pend`. |
| 148 | |
| 149 | At every moment `v + pend` *is* the fold at the thread's horizon β the swap |
| 150 | is horizon-neutral β so `takeSnapshot`/`seedThread` persist and restore |
| 151 | sandboxed state through the standard encrypted `thread_state` path, and a |
| 152 | closed app still accumulates events safely (they queue in `pend`, fold on |
| 153 | next open). Scheduled events and `notice` pushes ride the normal wire paths |
| 154 | untouched. |
| 155 | |
| 156 | ## Server surface (`memberapps.go`) |
| 157 | |
| 158 | | route | gate | notes | |
| 159 | |---|---|---| |
| 160 | | `GET /api/member-apps` | member token | blobs included (only clients can decrypt) | |
| 161 | | `PUT /api/member-apps/{id}` | member token | new id: any member (cap 200); existing: author, owner, or an agent asserting that same recorded owner | |
| 162 | | `DELETE /api/member-apps/{id}` | author or admin | admin = moderation without readability | |
| 163 | | `GET /app-sandbox` | none (static runtime) | the caged frame document | |
| 164 | | `GET /api/member-app-guide` | none | the authoring guide the bot builds against | |
| 165 | |
| 166 | Admins can switch the whole tier off with the `member_apps_enabled` setting |
| 167 | (Admin β Features β "Let members make their own apps", default on) β narrower |
| 168 | than the `apps_enabled` gate, which covers the entire apps feature. Off means: |
| 169 | the `PUT` above 403s (new deploys *and* edits), the guide 404s (which flips the |
| 170 | bot sidecar's `create_app` builder off β it treats a failed guide fetch as |
| 171 | "building disabled"), and App Studio hides from the app pickers (an |
| 172 | already-open App Studio thread drops its editor and says why; the deployed-apps |
| 173 | list stays, minus Edit code / Revert). Apps already deployed keep listing and |
| 174 | running, and `DELETE` stays open β the gate stops new use, never cleanup or |
| 175 | moderation. |
| 176 | |
| 177 | Ownership: the server's author is whoever deployed (the bot, for bot-built |
| 178 | apps). The *requester* is recorded inside the encrypted manifest (`owner`), |
| 179 | and the bot refuses to update an app for anyone but its requester β the gate |
| 180 | lives where the plaintext lives. The server's overwrite gate mirrors that |
| 181 | split: author or owner always may, and an **agent** that is neither may |
| 182 | overwrite *on the recorded owner's behalf* by asserting that same `owner_id` |
| 183 | (the visible half of the manifest check it already ran) β which is what lets |
| 184 | a bot fix an app the member wrote in App Studio, or one a different bot built |
| 185 | for them. Ownership never transfers via an overwrite, and owner-less legacy |
| 186 | rows stay closed to agents. Residual: chat payloads aren't |
| 187 | sender-authenticated (the thread key is symmetric β every member can encrypt |
| 188 | one), so a determined member could assert another's uid to redirect an |
| 189 | update, exactly as they could impersonate anyone in plain chat. That's a |
| 190 | property of the messaging layer, not this gate; the blast radius is capped by |
| 191 | the sandbox (a hijacked update only alters that app's own caged pane, never |
| 192 | keys or data). What the gate does enforce firmly: an unknown requester (no |
| 193 | uid) can't build or update anything, and an app with no recorded owner can't |
| 194 | be updated by anyone β so it never silently opens. |
| 195 | |
| 196 | Caps: a global bound (200 apps) and a per-author bound (40) are enforced in |
| 197 | one transaction so concurrent deploys can't race past them; the per-author |
| 198 | bound stops one member starving the shared global cap. |
| 199 | |
| 200 | ## App Studio β self-serve deploy (`web/static/apps/appstudio.js`) |
| 201 | |
| 202 | App Studio is the member-facing manager: a compiled-in **trusted** app (it |
| 203 | holds the token + instance key to encrypt/deploy, so it can't be a sandboxed |
| 204 | member app) that lists deployed apps and deploys pasted source directly β no |
| 205 | bot. It's the same doorway the bot uses (`PUT /api/member-apps`), driven by a |
| 206 | member instead. The privileged work lives in `web/static/sandbox.js` |
| 207 | (`listDeployedApps`, `deployMemberApp`, `retireMemberApp`, `extractManifest`, |
| 208 | `openMemberApp`); shell.js injects the token/instance-key/opener via |
| 209 | `setMemberAdmin`. |
| 210 | |
| 211 | The load-bearing piece is **manifest extraction**: rather than re-parse source |
| 212 | with regexes, App Studio runs the pasted code in a throwaway sandbox frame |
| 213 | (`extract` mode in `sandbox-frame.js` β `evalMemberApp` β `manifestOf`) and |
| 214 | reads back the *real* `defineApp` spec's name/title/glyph/types/commands/chat. |
| 215 | Untrusted code is evaluated only in the cage, and the extractor is the |
| 216 | authoritative source of truth (no drift with the runtime). `deployMemberApp` |
| 217 | then validates that manifest, encrypts `{ source, manifest }` under the |
| 218 | instance key, and PUTs it β minting a fresh id for a new app, or reusing the id |
| 219 | to update one you own. |
| 220 | |
| 221 | A **new** app registers live on deploy (usable immediately, no reload) and |
| 222 | suppresses this client's own `apps_changed` reload (`recentLocalDeploy`); an |
| 223 | **update** changes already-loaded code, so it rides the normal reload. |
| 224 | |
| 225 | ## Picking up a new app: offer, don't force |
| 226 | |
| 227 | When `apps_changed` arrives (a bot build, an App Studio deploy on another |
| 228 | device, or an admin custom-app install), the client does NOT reload out from |
| 229 | under you. It registers any new *member* apps live (so they work at once), then |
| 230 | surfaces a subtle, dismissible **"A new app is available β Reload / Later"** |
| 231 | nudge in the sidebar (`offerAppReload`, styled like the backup banner β inline, |
| 232 | never a floating toast). Reload is the reliable pickup for every tier (custom |
| 233 | apps are ES modules that need a fresh load); "Later" just hides it. The bot's |
| 234 | deploy confirmation reinforces the same low-pressure framing. |
| 235 | |
| 236 | ## Native |
| 237 | |
| 238 | Member apps ride the web shell, so the embedded webview (iOS/Mac render one |
| 239 | thread per webview) runs them without native code: `bootEmbed` registers the |
| 240 | proxies before rendering. A native Apps launcher entry is follow-up work; |
| 241 | until then an app thread created on the web appears in the native thread |
| 242 | list like any thread. |
| 243 | |
| 244 | ## Commands, companion chat, and formatting (in the frame) |
| 245 | |
| 246 | Three capabilities cross the sandbox boundary by running the app's untrusted |
| 247 | logic in the frame and letting the host act on the (validated) result: |
| 248 | |
| 249 | - **`../format.js`** β the pure text helpers are bundled *ahead of* the runtime |
| 250 | into the one `/app-sandbox` module (memberapps.go `sandboxBundle`), so a |
| 251 | member app's `import { richTokens } from "../format.js"` resolves with no |
| 252 | extra fetch. The CSP hash covers the whole bundle. |
| 253 | - **Slash commands** β the manifest carries command *metadata* (names, for the |
| 254 | `/` typeahead); the handlers live in the source and run in a frame. When the |
| 255 | app's thread is open its live frame runs the command; otherwise the host |
| 256 | mounts a transient hidden frame (seeded from the snapshot) just to execute |
| 257 | it. The handler's `ctx.emit` bridges out; its `ctx.reply({text,short})` comes |
| 258 | back and shows as the private confirmation where the user typed, with a |
| 259 | host-owned "Open <app>" chip (the app can't ship clickable code into the |
| 260 | host). Bridge: hostβframe `{type:"command",β¦}`, frameβhost |
| 261 | `{type:"command-reply",spec}`. |
| 262 | - **Companion chat** β the manifest flags `chat:true` (derived by the bot from |
| 263 | the source's `chatMessage`); the proxy gains a `chatMessage` so the framework |
| 264 | treats it as a companion (Chat + Interface views). The real projection runs |
| 265 | in the frame: it computes `chatMessage(event)` on fold and reports the chat |
| 266 | lines alongside state (snapshotted in the proxy's `{v,vUpTo,pend,chat}` so the |
| 267 | Chat view survives the window). The host folds each line into the thread's |
| 268 | Chat view via the same `chat.msg` path a trusted companion uses, deduped by |
| 269 | server message id. |
| 270 | |
| 271 | All three stay inside the trust model: only the app's own thread data, its own |
| 272 | manifest event types, and the projected/replied strings ever cross, all |
| 273 | size-capped and rate-limited; keys, tokens, other threads, and the parent DOM |
| 274 | never enter the frame. |
| 275 | |
| 276 | ## Legacy formats: the compat policy |
| 277 | |
| 278 | Member-app blobs are instance-key-encrypted user content: the server can't |
| 279 | read them, there is no census of what exists across (self-hosted, offline) |
| 280 | instances, and only the OWNER's client may rewrite one. So superseded |
| 281 | authoring vocabulary can't simply be dropped β but it doesn't get to sprawl |
| 282 | either. The policy, in deprecation-ladder order: |
| 283 | |
| 284 | 1. **Writers emit only the current vocabulary.** The bot's builder, |
| 285 | `manifestOf` (App Studio's extractor), and the `check-app.mjs` linter all |
| 286 | produce/teach the current form β nothing mints a legacy blob, ever. |
| 287 | 2. **Readers accept legacy at exactly ONE tested funnel per boundary** β |
| 288 | today: `normalizePlaces` in `defineApp` (the scope enum and the |
| 289 | personal/groupCapable/spaceCapable flags normalize into `places` and are |
| 290 | deleted) and the manifestβplaces derivation in `sandbox.js`. When a |
| 291 | concept is superseded, all first-party code migrates and the internal |
| 292 | reads are DELETED in the same change, so the funnel is the only place the |
| 293 | old vocabulary exists. (A concept that never reached the stored-manifest |
| 294 | boundary can be deleted outright with no funnel at all β `groupChatOnly`.) |
| 295 | 3. **Upgrades are opportunistic and owner-driven.** Any touch modernizes an |
| 296 | app: bot updates regenerate source against the current guide, and App |
| 297 | Studio flags legacy-format apps ("older format") with a one-tap |
| 298 | **Upgrade** β redeploying the same source re-mints a current manifest, |
| 299 | with the old version kept in History. Never rewrite a member's SOURCE |
| 300 | automatically: it's user content, and a transform bug bricks an app we |
| 301 | can't inspect. |
| 302 | 4. **The floor may eventually rise β but the accepted set is bounded and |
| 303 | written down, never infinite.** The full legacy set is currently: |
| 304 | manifests with `scope: "personal"` and/or the capability booleans in |
| 305 | place of `places`, and sources declaring the same fields in `defineApp`. |
| 306 | After a long, announced deprecation window (upgrade offers shipped well |
| 307 | before), `validateManifest` β the load gate β may refuse forms below the |
| 308 | floor; a refused app fails to register with a reason, exactly like any |
| 309 | invalid manifest, and its owner upgrades from App Studio. Raising the |
| 310 | floor is a deliberate, documented decision, never a side effect. |
| 311 | |
| 312 | ## Follow-ups (deliberately out of scope) |
| 313 | |
| 314 | - `messageAction` / `notify` / `preview` / `unread` for sandboxed apps. |
| 315 | - ~~Shared-scope (group) member apps~~ β SHIPPED since: a member app's |
| 316 | `places` may name any non-empty subset of you/chat/space, so chat-shared, |
| 317 | space-wide, and even space-only (gif-shaped, `listed: false`) member apps |
| 318 | are all legal (`docs/building-member-apps.md`). |
| 319 | - An admin-console list of member apps (App Studio lists a member's own; an |
| 320 | admin can delete any via the API today). |
| 321 | - In-place update of a running app without a reload (updates currently reload |
| 322 | to swap code). |
| 323 | |
| 324 | ## π€ Command panels (member apps) |
| 325 | |
| 326 | A member app's command may open a command panel (`ctx.panel` β the transient |
| 327 | surface above the composer). The trust posture is unchanged: |
| 328 | |
| 329 | - The panel BODY is a fresh `/app-sandbox` frame β the same opaque-origin, |
| 330 | no-network, no-keys cage as the app's own surface. The host never runs the |
| 331 | app's panel code in the page. |
| 332 | - Everything crosses the bridge as validated JSON: the request |
| 333 | (`sanitizePanelSpec` β clamped title, 64 KB data cap), emits from the panel |
| 334 | frame (the standard sanitize + rate limit), composer inserts (length-capped), |
| 335 | and content-height reports (clamped). |
| 336 | - A panel opens only while one of the app's commands is actually running (the |
| 337 | host resolves the opener by the command's sequence number), so a frame can't |
| 338 | pop panels spontaneously. |
| 339 | - The panel HEADER and β are host-drawn, outside the frame β dismissal can't |
| 340 | be trapped. A panel frame's `state` reports are ignored; the app-thread's |
| 341 | own frame stays the single writer. |
| 342 | |
| 343 | ## π€ Platform limitations: what member apps structurally can't do (yet) |
| 344 | |
| 345 | Building a full pack of *real* apps (a 21-app "family" set; |
| 346 | `web/static/apps-disabled/family/`) surfaced a consistent boundary worth |
| 347 | recording so it isn't rediscovered each time: member apps are well-suited to |
| 348 | **personal, cooperative, single-thread** tools (lists, trackers, journals, |
| 349 | games) and structurally **can't express roles/authority, per-member privacy, |
| 350 | targeted notification, or enforcement** β even though the *server* already holds |
| 351 | the primitives for most of it. |
| 352 | |
| 353 | - **No authority reaches apps.** Access levels (tiers, `docs/community-and-tiers.md`) |
| 354 | are admin-assignable roster roles β not just paid β *each carrying its own |
| 355 | key*: a ready-made "parents/children" or "moderators/members" primitive with |
| 356 | cryptographic backing. But `ctx` exposes no tiers and the sandbox withholds the |
| 357 | roster, so an app can gate nothing on a role. (In the family pack, an "ask a |
| 358 | parent" app degrades to "anyone but the asker".) |
| 359 | - **No per-member privacy in a thread.** One symmetric thread key β every member |
| 360 | decrypts every event and snapshot. An app can only *render-hide* (a courtesy, |
| 361 | not a vault); asymmetric secrecy needs cryptographic sealing the keyless |
| 362 | sandbox can't perform. |
| 363 | - **The fold can't authorise.** Pure reducers see only `e.uid`; every member can |
| 364 | emit every event, so "only X may do Y" is a render suggestion, never enforced. |
| 365 | - **No roster, no targeting, no directed notice.** Apps rebuild a pseudo-roster |
| 366 | from participation (who has emitted an event); a `notice` reaches everyone who |
| 367 | receives the event, not a chosen person or role. |
| 368 | - **Convergent fold vs asymmetry.** "The same events fold to the same state" |
| 369 | (load-bearing for snapshots + native parity + the event vectors) is exactly |
| 370 | what makes per-viewer or per-role state impossible except as a render illusion. |
| 371 | |
| 372 | **Widen-it path (roadmap, not built).** Expose tier membership to `ctx` for |
| 373 | admin-defined **role gating** (Level 1 β courtesy, but backed by a real role); |
| 374 | add host-mediated **seal-to-tier / seal-except** primitives for real enforcement |
| 375 | and privacy (Level 2 β the keyless sandbox can't do it alone); give `notice` a |
| 376 | target so an app can reach a person or a role. Fuller write-up, with per-app |
| 377 | evidence and the three-level "who sees what" model, lives in the family |
| 378 | reference pack's `DESIGN.md` (Β§ "Fit for purpose?" and Β§ "Viewer-aware |
| 379 | rendering"). |
| 380 | |