# Substratal Hub — macOS app UI specification

> Substratal Hub for macOS. Apple HIG — macOS 26. Stack: SwiftUI (min macOS 15). Built against the Substratal Apps Platform API v1.4.0 (https://compositecode.github.io/substratalapps.com/).

This file is the AI-readable version of https://compositecode.github.io/substratalapps-applications-specs/platforms/macos.html. Behavior, data and copy are identical on all six platforms and defined in the shared spec; this platform's documents define containers, native controls, integrations and theming.

## At a glance

- App: macOS
- Stack: SwiftUI (min macOS 15)
- Notifications: APNs (as app_hub)
- Screens specified: 75 (see SCREENS.md below); reference renders: 17, each in light (Moss Paper) and dark (Forest Floor)
- Themes: 10 (Teal Ledger, Indigo Root, Moss Paper, Coral Clay, Spore Pink; Night Soil, Deep Teal, Graphite Spore, Forest Floor, Ember Clay); defaults moss-paper / forest-floor
- Mockups accept `?mode=dark&light=<theme-id>&dark=<theme-id>`
- Source folder: https://github.com/CompositeCode/substratalapps-applications-specs/tree/main/substratal-application-specs/macos

## macOS documents

- [Team handoff](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/README.html) — Markdown: https://compositecode.github.io/substratalapps-applications-specs/spec/macos/README.md
- [Platform spec](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/PLATFORM-SPEC.html) — Markdown: https://compositecode.github.io/substratalapps-applications-specs/spec/macos/PLATFORM-SPEC.md
- [Screens](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/SCREENS.html) — Markdown: https://compositecode.github.io/substratalapps-applications-specs/spec/macos/SCREENS.md
- [brand-assets/icon-composer-layers](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/brand-assets/icon-composer-layers/README.html) — Markdown: https://compositecode.github.io/substratalapps-applications-specs/spec/macos/brand-assets/icon-composer-layers/README.md

## Shared spec (reading order)

- [What changed (v1.4.0)](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/CHANGES.md): What changed in this edition
- [Product spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/PRODUCT-SPEC.md): Scope, personas, domains, phases, acceptance criteria
- [Screen specs](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.md): Every screen: route, data, elements, actions, states
- [Client architecture](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/CLIENT-ARCHITECTURE.md): Sessions, requests, Capabilities, push and inbox, billing
- [UX flows](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/UX-FLOWS.md): The 16 end-to-end flows
- [Information architecture](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/INFORMATION-ARCHITECTURE.md): Navigation tree, routes, visibility rules, deep links
- [Themes](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/THEMES.md): Theme system: roles, palettes, contrast, persistence
- [Logo standard](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/brand/BRAND.md): Logo standard and app icons
- [Components](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/COMPONENTS.md): Component behavior contracts and native mapping
- [Copy & states](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/COPY-AND-STATES.md): Labels, confirmations, empty states, errors
- [Accessibility](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/ACCESSIBILITY.md): Accessibility requirements
- [Non-functional](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/NON-FUNCTIONAL.md): Performance, security, reliability
- [Open questions](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/OPEN-QUESTIONS.md): Unresolved decisions
- [API → screen map (CSV)](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/API-SCREEN-MAP.csv): all 118 API operations → screens
- [Theme tokens (JSON)](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/themes/themes.json) · [Brand tokens (JSON)](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/brand/brand-tokens.json) · [Status semantics (JSON)](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/status-semantics.json)

## Reference screens

| Screen | Title | Light | Dark | Live mockup | Spec |
|---|---|---|---|---|---|
| ACC-04 | Security | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/ACC-04-security.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/ACC-04-security.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/ACC-04-security.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#acc-04--security) |
| ACX-01/03 | User — Access | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/ACX-01-03-access-turn-off.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/ACX-01-03-access-turn-off.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/ACX-01-03-access-turn-off.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#acx-01--user--access) |
| APL-03 | Application — Overview | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/APL-03-application.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/APL-03-application.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/APL-03-application.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#apl-03--application--overview) |
| APL-07 | Push credentials | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/APL-07-push-credentials.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/APL-07-push-credentials.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/APL-07-push-credentials.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#apl-07--push-credentials) |
| APP-01 | Home — My Apps | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/APP-01-home.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/APP-01-home.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/APP-01-home.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#app-01--home--my-apps) |
| AUD-01/02 | Audit log | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/AUD-01-02-audit.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/AUD-01-02-audit.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/AUD-01-02-audit.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#aud-01--audit-log) |
| AUTH-01/02 | Sign in | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/AUTH-01-02-sign-in.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/AUTH-01-02-sign-in.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/AUTH-01-02-sign-in.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#auth-01--sign-in) |
| BIL-01 | My plan | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/BIL-01-my-plan.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/BIL-01-my-plan.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/BIL-01-my-plan.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#bil-01--my-plan) |
| KEY-01/03 | API keys | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/KEY-01-03-api-keys-secret.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/KEY-01-03-api-keys-secret.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/KEY-01-03-api-keys-secret.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#key-01--api-keys) |
| NTF-01/02 | Inbox | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/NTF-01-02-inbox.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/NTF-01-02-inbox.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/NTF-01-02-inbox.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#ntf-01--inbox) |
| NTF-03 | Devices & notifications | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/NTF-03-devices.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/NTF-03-devices.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/NTF-03-devices.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#ntf-03--devices--notifications) |
| ORG-06 | Organization — Apps & seats | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/ORG-06-org-apps-seats.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/ORG-06-org-apps-seats.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/ORG-06-org-apps-seats.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#org-06--organization--apps--seats) |
| ROL-02 | Role detail | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/ROL-02-role.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/ROL-02-role.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/ROL-02-role.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#rol-02--role-detail) |
| SET-01 | Settings — Global | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/SET-01-appearance-themes.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/SET-01-appearance-themes.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/SET-01-appearance-themes.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#set-01--settings--global) |
| SET-02 | Settings — Per app | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/SET-02-app-settings.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/SET-02-app-settings.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/SET-02-app-settings.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#set-02--settings--per-app) |
| USR-01 | Users | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/USR-01-users.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/USR-01-users.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/USR-01-users.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#usr-01--users) |
| WHK-03 | Webhook — Deliveries | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/light/WHK-03-deliveries.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/screens/dark/WHK-03-deliveries.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/WHK-03-deliveries.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#whk-03--webhook--deliveries) |

Mockup index: https://compositecode.github.io/substratalapps-applications-specs/spec/macos/mockups/index.html

## Theme implementation files

- https://compositecode.github.io/substratalapps-applications-specs/spec/macos/theme/Assets.xcassets/AccentColor.colorset/Contents.json
- https://compositecode.github.io/substratalapps-applications-specs/spec/macos/theme/Assets.xcassets/Contents.json
- https://compositecode.github.io/substratalapps-applications-specs/spec/macos/theme/SubstratalTheme.swift

Brand assets: https://github.com/CompositeCode/substratalapps-applications-specs/tree/main/substratal-application-specs/macos/brand-assets

---

## Full text: macos/README.md

Source: https://compositecode.github.io/substratalapps-applications-specs/spec/macos/README.md

**Standard:** Apple HIG — macOS 26 · **Stack:** SwiftUI (min macOS 15)
**API:** Substratal Apps Platform API v1.4.0 (openapi 1.0.0-draft.3) — https://compositecode.github.io/substratalapps.com/

### Reading order

1. `shared/CHANGES.md` — what's new in this edition (push notifications, Free/Standard billing)
2. `shared/PRODUCT-SPEC.md` — scope, personas, domains, phases, acceptance criteria
3. `PLATFORM-SPEC.md` — how this app is built on macOS
4. `SCREENS.md` — all 75 screens with native containers and controls (the build checklist)
5. `shared/CLIENT-ARCHITECTURE.md` — sessions, request conventions, Capabilities, push and inbox (§6), billing hand-off (§9)
6. `shared/UX-FLOWS.md` (+ `shared/flows/*.svg`) — the 16 end-to-end flows
7. `mockups/index.html` / `screens/` — reference renders in the default light (Moss Paper) and dark (Forest Floor) themes; `?mode=dark&light=<id>&dark=<id>` previews any theme
8. While building: `shared/COMPONENTS.md`, `shared/COPY-AND-STATES.md`, `shared/ACCESSIBILITY.md`, `shared/NON-FUNCTIONAL.md`, `shared/OPEN-QUESTIONS.md`, `shared/API-SCREEN-MAP.csv`

**Themes:** `theme/` has this platform's implementation of the 5 light and 5 dark themes. Rules are in `shared/THEMES.md`; wiring is in `PLATFORM-SPEC.md` (Theming section).

**Logo & app icon:** `brand-assets/` has every icon and logo file this app needs. Wiring is in `PLATFORM-SPEC.md` (Brand & app icon section) and rules in `shared/brand/BRAND.md`.

### Definition of done (per screen)

- [ ] Elements, data bindings and API calls match `SCREENS.md`
- [ ] Reachable by its route / deep link; restores state from the route
- [ ] Gated by Capabilities per `shared/INFORMATION-ARCHITECTURE.md` §2
- [ ] Loading, empty, error (401/403/404/409/422/429/503) and offline states
- [ ] Idempotency-Key on creates, If-Match on edits, conflict handling
- [ ] Every gesture/shortcut has a visible equivalent; screen reader and largest text size pass
- [ ] Copy matches `shared/COPY-AND-STATES.md`
- [ ] Correct in both default themes and in every other theme (colours only from theme roles, no literals); high-contrast modes fall back to system colours
- [ ] Logo and icons come only from `brand-assets/` (no redraws), with accessible name "Substratal Apps"
- [ ] Flag-gated features (notifications, push, billing) hide cleanly on `501`

### Reference screens

- `ACC-04-security`: [light](screens/light/ACC-04-security.png) · [dark](screens/dark/ACC-04-security.png)
- `ACX-01-03-access-turn-off`: [light](screens/light/ACX-01-03-access-turn-off.png) · [dark](screens/dark/ACX-01-03-access-turn-off.png)
- `APL-03-application`: [light](screens/light/APL-03-application.png) · [dark](screens/dark/APL-03-application.png)
- `APL-07-push-credentials`: [light](screens/light/APL-07-push-credentials.png) · [dark](screens/dark/APL-07-push-credentials.png)
- `APP-01-home`: [light](screens/light/APP-01-home.png) · [dark](screens/dark/APP-01-home.png)
- `AUD-01-02-audit`: [light](screens/light/AUD-01-02-audit.png) · [dark](screens/dark/AUD-01-02-audit.png)
- `AUTH-01-02-sign-in`: [light](screens/light/AUTH-01-02-sign-in.png) · [dark](screens/dark/AUTH-01-02-sign-in.png)
- `BIL-01-my-plan`: [light](screens/light/BIL-01-my-plan.png) · [dark](screens/dark/BIL-01-my-plan.png)
- `KEY-01-03-api-keys-secret`: [light](screens/light/KEY-01-03-api-keys-secret.png) · [dark](screens/dark/KEY-01-03-api-keys-secret.png)
- `NTF-01-02-inbox`: [light](screens/light/NTF-01-02-inbox.png) · [dark](screens/dark/NTF-01-02-inbox.png)
- `NTF-03-devices`: [light](screens/light/NTF-03-devices.png) · [dark](screens/dark/NTF-03-devices.png)
- `ORG-06-org-apps-seats`: [light](screens/light/ORG-06-org-apps-seats.png) · [dark](screens/dark/ORG-06-org-apps-seats.png)
- `ROL-02-role`: [light](screens/light/ROL-02-role.png) · [dark](screens/dark/ROL-02-role.png)
- `SET-01-appearance-themes`: [light](screens/light/SET-01-appearance-themes.png) · [dark](screens/dark/SET-01-appearance-themes.png)
- `SET-02-app-settings`: [light](screens/light/SET-02-app-settings.png) · [dark](screens/dark/SET-02-app-settings.png)
- `USR-01-users`: [light](screens/light/USR-01-users.png) · [dark](screens/dark/USR-01-users.png)
- `WHK-03-deliveries`: [light](screens/light/WHK-03-deliveries.png) · [dark](screens/dark/WHK-03-deliveries.png)

---

## Full text: macos/PLATFORM-SPEC.md

Source: https://compositecode.github.io/substratalapps-applications-specs/spec/macos/PLATFORM-SPEC.md

**Standard:** Apple Human Interface Guidelines for macOS (macOS 26 Tahoe; Liquid Glass applies automatically to standard controls — don't hand-draw it).
**Read with:** `SCREENS.md` (all 75 screens with macOS controls), `shared/` (product spec, client architecture, components, copy, accessibility, flows).
**Role in the family:** the macOS app is the **reference design** — the Web app's desktop layout is derived from it.

### 1. Technology

| Concern | Choice |
|---|---|
| UI | SwiftUI app lifecycle; AppKit bridges only for `NSTokenField`, `NSWindow.sharingType`, Services |
| Minimum OS | macOS 15 (Sequoia); test on macOS 26 |
| Architecture | Swift Package `SubstratalKit` shared with iOS: generated API client (`swift-openapi-generator`), domain services, Capabilities, persistence. App target holds views + view models (`@Observable`). |
| Concurrency | Swift structured concurrency; one `actor SessionStore` serializes token refresh (single-flight) |
| Persistence | Keychain (refresh token); SwiftData or file cache (encrypted with Data Protection) for catalog/entitlements/profile |
| Networking | URLSession; HTTP/3 enabled; `URLCache` disabled for API (cache is explicit) |
| Distribution | Mac App Store **and** notarized Developer ID build (direct download) with Sparkle updates; same codebase, entitlements differ |

### 2. Scenes & windows

| Scene | Purpose |
|---|---|
| `WindowGroup` main | `NavigationSplitView` sidebar · content · detail. Default 1280×800, min 900×600. State restoration per window. Multiple windows allowed (⌘N) — each with its own navigation. |
| `WindowGroup(for: Route.self)` | Open any user/app/org/role in its own window (⌘-double-click, "Open in New Window"). |
| `Window("Sign in")` | AUTH-* when signed out; 720×560 fixed, hides main windows. |
| `Settings` scene (⌘,) | SET-01 global settings + app preferences (analytics, shortcuts, launch at login). Per-app settings (SET-02) live in the main window. |
| `MenuBarExtra` (opt-in) | Quick launcher: pinned + recent apps, inbox count. |

### 3. Navigation

- **Sidebar** (`List(selection:)`, `.listStyle(.sidebar)`): Personal (Home, Inbox, Organizations, Account, Plan & Billing, Settings) · Administration (Users, Access, Roles, Audit Log, Send notification) · Developer (Applications, API Keys, Webhooks, Tenant), collapsible section headers, `.badge()` counts (unread on Inbox, pending invitations on Organizations). Sidebar search field opens the command palette (GLB-01).
- **Columns:** 2-column for Home, Account, Plan & Billing, Tenant; 3-column for Inbox (list · notification), Settings (scopes · form), Users, Access, Organizations, Roles, Applications, Keys, Webhooks; Audit and a user's Notifications tab use a `Table` with a trailing `.inspector`.
- **Toolbar:** `.navigationTitle` + `.navigationSubtitle`; primary action in `.primaryAction`; grouped items share one capsule (`ToolbarItemGroup`); search via `.searchable(placement: .toolbar)` on list screens; Inbox bell + account menu at trailing edge.
- **Tabs inside detail:** segmented `Picker` in the toolbar's principal position (Overview · Access · Roles · Sessions · Activity).
- **Routing:** a `Route` enum mirrors the routes in SCREEN-SPECS; Universal Links + `substratal://` resolve to `Route`.

### 4. Interaction

| Interaction | Behavior |
|---|---|
| Context menus | `.contextMenu` on tiles, rows, IDs, sidebar items — same items as the row's ⋯ button / toolbar |
| Multi-select | `Table`/`List` selection: click, ⇧-click, ⌘-click, ⌘A; bulk actions in toolbar + context menu (OPEN-QUESTIONS #6) |
| Drag & drop | users → organization in sidebar (invite); app tiles reorder pins; drop CSV/TXT of emails on Users/Members (bulk invite review); drop image on avatar/icon; drop JSON on schema editor; drag export / audit event out as `.json` (`Transferable` with `FileRepresentation`) |
| Quick Look–style preview | `Space` on a selected row opens a preview popover; hovering a name for 0.5 s shows the same as a popover |
| Undo | access toggles register with `UndoManager` (⌘Z) for 10 s |
| Services / Share | Share menu (`ShareLink`) for export files and app links |
| Secret reveal | sheet with `NSWindow.sharingType = .none` while visible (excluded from screen capture) |

**Keyboard shortcuts** (all appear in menus — HIG requires discoverability):

| Shortcut | Action | Menu |
|---|---|---|
| ⌘K | Command palette | Go |
| ⌘1 … ⌘6 | Home, Inbox, Organizations, Account, Plan & Billing, Settings | Go |
| ⇧⌘U | Mark all as read (Inbox) | Edit |
| ⌥⌘1 … ⌥⌘4 | Users, Access, Roles, Audit Log | Go |
| ⌃⌘1 … ⌃⌘4 | Applications, API Keys, Webhooks, Tenant | Go |
| ⌘N / ⇧⌘N | New window / contextual New (Invite user, New role, Create key…) | File |
| ⌘F | Focus search | Edit |
| ⌘R | Refresh | View |
| ⌃⌘S | Toggle sidebar | View |
| ⌥⌘I | Toggle inspector | View |
| ⌘Z | Undo access toggle | Edit |
| ⌘C | Copy ID of selection | Edit |
| ⌘⌫ | Destructive action on selection (confirms) | Edit |
| ⌘↩ | Submit sheet | — |
| Space | Preview | — |

Standard menus: App, File, Edit, View, Go, Window, Help (Help → "API Specification" opens the spec site).

### 5. Platform integration

| Concern | Implementation |
|---|---|
| Secure storage | Keychain `kSecClassGenericPassword`, `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, not synchronizable |
| Re-open lock (opt-in) | Touch ID via `LAContext`; Keychain item with `SecAccessControl(.userPresence)` |
| Password autofill | Associated Domains `webcredentials:dashboard.substratalapps.com`; text content types on AUTH fields; passkeys via `ASAuthorizationController` when flag `passkeys` is on |
| Deep links | Universal Links (`applinks:dashboard.substratalapps.com`) + `substratal://`; `onOpenURL` routes to `Route` |
| App launch (APP-03) | `NSWorkspace.shared.open(url)`; PKCE via `ASWebAuthenticationSession` |
| Billing (BIL-03) | Checkout/Portal open in the default browser; return via Universal Link `/billing/return` (or `/orgs/{id}/subscription`), then poll until `plan = standard`. The Mac App Store build gates Upgrade behind `billing_upgrade_ios` (same App Store policy as iOS); the Developer ID build always shows it. |
| Notifications | APNs as `app_hub` — see §5a; Dock badge = `unread-count` (`NSApp.dockTile.badgeLabel`) |
| Files | `fileExporter`/`fileImporter`; no temp copies |
| Login item | `SMAppService.mainApp` for "Open at login" (Settings) |
| Background | refresh `unread-count` every 60 s while a window is visible; pushes cover the rest |

### 5a. Push notifications (APNs)

| Step | Implementation |
|---|---|
| Bundle id | Same as the iOS app (Universal Purchase) so the single `app_hub` APNs credential (`topic`) reaches both |
| Capability | Push Notifications entitlement (`aps-environment`: development for debug, production for release); Time Sensitive Notifications for `security.*` |
| Permission | NTF-04 primer → `UNUserNotificationCenter.requestAuthorization([.alert, .badge, .sound])` — ask when the person turns notifications on or at the first invitation, not at launch |
| Register | `NSApplication.shared.registerForRemoteNotifications()` → hex token → `POST /users/me/devices {application_id: "app_hub", provider: "apns", token, environment: sandbox/production, name: Host.current().localizedName, app_version}` |
| Refresh / rotate | Re-register once a day on launch; a changed token → `PATCH /users/me/devices/{id}` |
| Categories | `UNNotificationCategory` per COPY-AND-STATES (e.g. `security.new_sign_in`: It was me / Review sessions; `account.organization_invitation`: Accept / View) |
| Delivery | `userNotificationCenter(_:willPresent:)` → in-app banner + inbox refresh; `didReceive` → mark read + route by `action_url` |
| Turn off | NTF-03 "Turn off on this Mac" → `DELETE /users/me/devices/{id}` and `unregisterForRemoteNotifications()` |

### 5b. Brand & app icon

Assets: `brand-assets/` (see `shared/brand/BRAND.md`).

| Item | File | Wiring |
|---|---|---|
| App icon | `Assets.xcassets/AppIcon.appiconset` (16–512 pt @1x/@2x, macOS rounded-rect body baked in; 16/32 px use the small mark) and `AppIcon.icns` | Set *App Icon* to `AppIcon`. Use the `.icns` for non-Xcode packaging. |
| Liquid Glass icon (macOS 26+) | `icon-composer-layers/` | Build `AppIcon.icon` in Icon Composer. The set above is the fallback. |
| Logo images and colours | `Assets.xcassets/Brand/*`, identical to iOS (lives in `SubstratalKit` resources) | `Image("Brand/BrandLockup")` |
| Menu-bar extra (optional) | `Assets.xcassets/MenuBarIcon` (template SVG) and `menubar-template/*.png` | `MenuBarExtra { … } label: { Image("MenuBarIcon") }`. It's a template image, so the system tints it. |
| Dock badge | — | `NSApp.dockTile.badgeLabel` from `unread-count` |

Placement: the sign-in window shows the horizontal lockup. **About Substratal** (standard About panel) uses the app icon. Sidebars and toolbars have no logo, per HIG.


### 5c. Theming

Same `theme/SubstratalTheme.swift` as iOS (in `SubstratalKit`).

- The sidebar uses `palette.sidebar`; keep the translucent sidebar material and set its tint to `palette.sidebar` at 92 %.
- Content panes use `palette.raised` and the window background uses `palette.background`.
- List and table selection uses `palette.accent` with `palette.onAccent` text, not the system accent.
- Set `NSApp.appearance` from Settings.theme (`nil` for Match system).
- SET-01 shows the swatches as a five-column grid in the Settings window.

### 6. Layout metrics

- Sidebar 220 pt default (resizable 180–320). Content column 280–360 pt. Inspector 280–340 pt.
- Content insets 20 pt; 8 pt between related controls; 20 pt between groups.
- Table rows 24 pt (text-only) / 32 pt (avatars). Form rows ≥ 28 pt.
- Text styles only (`.title2` detail titles, `.headline`, `.body` 13 pt, `.callout`, `.caption`); IDs `.monospaced()`.
- Icons: SF Symbols per INFORMATION-ARCHITECTURE §5.

### 7. Accessibility

VoiceOver rotor on tables; tiles and rows expose custom actions (`.accessibilityActions`) for everything in their context menu; switches labeled "Access to {app}". Full Keyboard Access; visible focus; Dynamic Type n/a on macOS but respect system text size where SwiftUI scales; Increase Contrast, Reduce Motion, Reduce Transparency honored by system controls.

### 8. Testing checklist

- [ ] APNs: permission, register (sandbox + production), daily refresh, token change, sign-out deactivation, category actions, Dock badge from `unread-count`.
- [ ] Stripe Checkout/Portal round trip in test mode (Developer ID build) and the "Finishing up…" poll.
- [ ] All 75 screens via routes; flags on/off per phase.
- [ ] Persona matrix (six personas) — sidebar contents and every action button.
- [ ] 11 flows with mouse only, keyboard only (Full Keyboard Access), VoiceOver.
- [ ] Multi-window: same user open in two windows stays consistent after edits (refetch on focus).
- [ ] Sleep/wake and network change: token refresh single-flight, no duplicate writes.
- [ ] App Store sandbox build and Developer ID build both pass.

---

## Full text: macos/SCREENS.md

Source: https://compositecode.github.io/substratalapps-applications-specs/spec/macos/SCREENS.md

All 75 screens with the native macOS container and controls for each element. Behavior, data and copy are identical to `shared/SCREEN-SPECS.md`; construction rules for this platform are in `PLATFORM-SPEC.md`.

### Container per archetype

| Archetype | Container |
|---|---|
| auth | Separate `Window` scene (720×560, no sidebar) |
| grid | Detail column of `NavigationSplitView`, `LazyVGrid` |
| list | Content column (`List(selection:)`) + detail column |
| table | Detail column with `Table`, inspector optional |
| detail | Detail column; tabs as segmented `Picker` in toolbar principal |
| tab | Tab content inside detail |
| form | `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels |
| settings | Detail column `Form(.grouped)`; per-row save |
| confirm | `.confirmationDialog` or sheet when it has fields |
| secret | Sheet, `interactiveDismissDisabled`, `sharingType = .none` |
| wizard | Sheet with paged steps + Back/Continue |
| popover | `.popover` or `.inspector` |
| handoff | `NSWorkspace.open` / `ASWebAuthenticationSession` |
| overlay | Floating panel / toolbar popover |
| hub | — (sidebar replaces hubs) |

### D1 · Sign-in & Identity

#### AUTH-01 — Sign in
**Domain:** D1 Sign-in & Identity · **Route:** `/sign-in` · **Archetype:** auth · **Phase:** MVP  
**Who:** Unauthenticated  
**Purpose:** Exchange email + password for a session, or start an MFA challenge.

**macOS container:** Separate `Window` scene (720×560, no sidebar)

**Entry points:** App launch with no stored session; Session expired / revoked (preserves `?next=` destination); Sign out

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Email | email-field | TextField .textContentType(.username) .keyboardType n/a | body.email | required; RFC 5322; trim; autofill username |
| Password | password-field | SecureField .textContentType(.password/.newPassword) | body.password | required; reveal toggle; autofill current-password |
| Sign in | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | POST /auth/login | disabled until both fields non-empty |
| Forgot password? | link | Button .link | → AUTH-05 |  |
| Create an account | link | Button .link | → AUTH-03 |  |
| Continue with SSO | button | Button .bordered | POST /auth/sso/{provider}/callback | hidden behind flag `sso` (API returns 501 today) |
| Sign in with a passkey | button | Button .bordered | — | hidden behind flag `passkeys`; reserved for when the API supports WebAuthn |

| Action | Call | Result handling |
|---|---|---|
| Sign in | `POST /auth/login` | 200 AuthSession → store tokens → go to `next` or APP-01. 200 MfaChallenge → AUTH-02 with mfa_token. |
| — |  | 401 → inline form error "Email or password is incorrect." (never reveal which). 403 → account suspended/locked message. 429 → disable button, countdown from Retry-After. |

**States:** Submitting: button spinner, fields read-only; Locked out (5 failures / 15 min): explain wait time

**Notes:** `test_mode` is never exposed in end-user UI; developer builds may show a hidden toggle (long-press logo / ⌥-click) for test-mode sign-in. The horizontal logo lockup (BrandLockup, `brand/BRAND.md`) sits above the form.

#### AUTH-02 — Two-factor challenge
**Domain:** D1 Sign-in & Identity · **Route:** `/sign-in/mfa` · **Archetype:** auth · **Phase:** MVP  
**Who:** Holder of mfa_token  
**Purpose:** Complete sign-in with a TOTP code or a recovery code.

**macOS container:** Separate `Window` scene (720×560, no sidebar)

**Entry points:** AUTH-01 returned MfaChallenge

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Code | otp-field | TextField .textContentType(.oneTimeCode) | body.code | 6 digits; paste fills all; auto-submit on 6th digit; OS one-time-code autofill |
| Use a recovery code | link | Button .link | swaps to Recovery code field | only if `methods` includes recovery_code |
| Recovery code | text-field | TextField | body.recovery_code | format xxxx-xxxx; case-insensitive |
| Expiry countdown | caption | Text .font(.caption) .foregroundStyle(.secondary) | MfaChallenge.expires_in | announce at 60 s and 10 s |
| Verify | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | POST /auth/mfa/verify |  |

| Action | Call | Result handling |
|---|---|---|
| Verify | `POST /auth/mfa/verify` | 200 → same as AUTH-01 success. 401 → "That code didn't work." clear field. Expired → back to AUTH-01 with message. 429 → countdown. |

**States:** Challenge expired

#### AUTH-03 — Create account
**Domain:** D1 Sign-in & Identity · **Route:** `/sign-up` · **Archetype:** auth · **Phase:** MVP  
**Who:** Unauthenticated  
**Purpose:** Self-service signup with email and password.

**macOS container:** Separate `Window` scene (720×560, no sidebar)

**Entry points:** AUTH-01 link; Marketing/app deep link with optional `application_id`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Display name | text-field | TextField | body.display_name | optional; ≤ 100 |
| Email | email-field | TextField .textContentType(.username) .keyboardType n/a | body.email | required |
| Password | password-field | SecureField .textContentType(.password/.newPassword) | body.password | required; strength meter; autofill new-password; show policy hint |
| Create account | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | POST /auth/signup |  |
| Terms / Privacy | links | Link | external |  |

| Action | Call | Result handling |
|---|---|---|
| Create account | `POST /auth/signup` | 201 AuthSession → AUTH-04 (verify pending) then APP-01. 409 email_taken → "An account with this email may already exist. Sign in or reset your password." (with details.reason invitation_pending the API re-sends the invitation: say "Check your email for your invitation.") 422 → field errors. |

**Notes:** Pass `application_id` through when signup started from an app's link; it is recorded as signup_application_id. Every new User starts on the Free plan and receives platform-managed `free` Entitlements to every free Application, including the Hub itself (`app_hub`). No plan choice at signup; upgrading is BIL-01.

#### AUTH-04 — Verify your email
**Domain:** D1 Sign-in & Identity · **Route:** `/verify-email` · **Archetype:** auth · **Phase:** MVP  
**Who:** Signed in (unverified) or token holder  
**Purpose:** Tell the user to check email; complete verification from the emailed link.

**macOS container:** Separate `Window` scene (720×560, no sidebar)

**Entry points:** After signup; Persistent banner on APP-01 while `email_verified` is false; Deep link `/verify-email?token=…`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Instruction | text | Text | User.email | "We sent a link to {email}." |
| Resend email | button | Button .bordered | POST /auth/email/verify/resend | 30 s cooldown after each send |
| Continue to Substratal | button | Button .bordered | → APP-01 | unverified users may continue |
| Change email | link | Button .link | → ACC-02 |  |

| Action | Call | Result handling |
|---|---|---|
| Deep link opened | `POST /auth/email/verify {token}` | 204 → success state, refresh User. 400 → "This link has expired." + Resend. |
| Resend email | `POST /auth/email/verify/resend` | Always 202 → toast "If that address needs verifying, we sent a new link." |

#### AUTH-05 — Forgot password
**Domain:** D1 Sign-in & Identity · **Route:** `/forgot-password` · **Archetype:** auth · **Phase:** MVP  
**Who:** Unauthenticated  
**Purpose:** Request a password-reset email.

**macOS container:** Separate `Window` scene (720×560, no sidebar)

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Email | email-field | TextField .textContentType(.username) .keyboardType n/a | body.email | prefilled from AUTH-01 |
| Send reset link | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | POST /auth/password/forgot |  |

| Action | Call | Result handling |
|---|---|---|
| Send reset link | `POST /auth/password/forgot` | Always 202 → confirmation "If an account exists for {email}, we sent a reset link." 429 → countdown. |

#### AUTH-06 — Set a new password
**Domain:** D1 Sign-in & Identity · **Route:** `/reset-password` · **Archetype:** auth · **Phase:** MVP  
**Who:** Reset-token holder  
**Purpose:** Choose a new password from the emailed reset link.

**macOS container:** Separate `Window` scene (720×560, no sidebar)

**Entry points:** Deep link `/reset-password?token=…`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| New password | password-field | SecureField .textContentType(.password/.newPassword) | body.new_password | strength meter; autofill new-password |
| Confirm password | password-field | SecureField .textContentType(.password/.newPassword) | client only | must match |
| Notice | caption | Text .font(.caption) .foregroundStyle(.secondary) |  | "You'll be signed out on all devices." |
| Save password | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | POST /auth/password/reset |  |

| Action | Call | Result handling |
|---|---|---|
| Save password | `POST /auth/password/reset` | 204 → clear local session → AUTH-01 with success banner. 400 → expired link + link to AUTH-05. 422 → field errors. |

#### AUTH-07 — Accept invitation
**Domain:** D1 Sign-in & Identity · **Route:** `/invite` · **Archetype:** auth · **Phase:** MVP  
**Who:** Invitation-token holder  
**Purpose:** Activate an account an admin created: set password and sign in.

**macOS container:** Separate `Window` scene (720×560, no sidebar)

**Entry points:** Deep link `/invite?token=…` from the invitation email

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Display name | text-field | TextField | body.display_name | optional |
| Password | password-field | SecureField .textContentType(.password/.newPassword) | body.password | strength meter |
| Accept and continue | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | POST /auth/invitations/accept |  |

| Action | Call | Result handling |
|---|---|---|
| Accept and continue | `POST /auth/invitations/accept` | 200 AuthSession → APP-01. 400 → "This invitation is no longer valid. Ask your admin to resend it." |


### D2 · Account & Security

#### ACC-01 — Account & profile
**Domain:** D2 Account & Security · **Route:** `/account` · **Archetype:** settings · **Phase:** MVP  
**Who:** Self  
**Purpose:** View and edit the signed-in user's global profile.

**macOS container:** Detail column `Form(.grouped)`; per-row save

**Loads:** `GET /users/me` · `GET /users/me/profile`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Avatar | image-picker | PhotosPicker + fileImporter + paste/drop | Profile.avatar_url | camera / photo library / file / paste; client uploads to app asset storage then PATCHes URL* |
| Display name | text-field | TextField | Profile.display_name | required; ≤ 100; save on blur/Return |
| Email | row → ACC-02 | NavigationLink row | User.email + email_verified + pending_email | shows "Pending: {pending_email}" when set |
| User ID | id-chip | Text(.monospaced) + copy Button | User.id | copy |
| Contact email | email-field | TextField .textContentType(.username) .keyboardType n/a | Profile.contact_email | optional |
| Contact phone | phone-field | TextField .textContentType(.telephoneNumber) | Profile.contact_phone | optional; E.164 formatting by locale |
| Plan | row → BIL-01 | NavigationLink row | User.plan free\|standard + plan_sources | "Free" or "Standard · via {org}" / "Standard · your subscription" |
| Member since / last sign-in | caption | Text .font(.caption) .foregroundStyle(.secondary) | User.created_at, last_login_at | locale formatted |
| Sign out | button | Button .bordered | POST /auth/logout | always visible at the bottom of Account (mobile) / account menu (desktop) |

| Action | Call | Result handling |
|---|---|---|
| Edit field | `PATCH /users/me/profile (If-Match)` | Save per field; 409 version_conflict → reload prompt; 422 → field error. |
| Sign out | `POST /auth/logout` | best effort; always wipe local credentials and cache → AUTH-01 |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

**Notes:** *The API stores `avatar_url` only. Upload target is a client-side decision (spec gap — see OPEN-QUESTIONS).

#### ACC-02 — Change email
**Domain:** D2 Account & Security · **Route:** `/account/email` · **Archetype:** form · **Phase:** MVP  
**Who:** Self  
**Purpose:** Start an email change; the new address must be verified.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| New email | email-field | TextField .textContentType(.username) .keyboardType n/a | body.email | required; differs from current |
| Save | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | PATCH /users/me |  |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /users/me {email}` | 200 → User.pending_email set; show AUTH-04-style "check your new inbox". 409 not_unique → "That email is already in use." |

#### ACC-03 — Change password
**Domain:** D2 Account & Security · **Route:** `/account/security/password` · **Archetype:** form · **Phase:** MVP  
**Who:** Self  
**Purpose:** Change password knowing the current one.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Current password | password-field | SecureField .textContentType(.password/.newPassword) | body.current_password | autofill current-password |
| New password | password-field | SecureField .textContentType(.password/.newPassword) | body.new_password | strength meter; autofill new-password |
| Save | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | POST /users/me/password |  |

| Action | Call | Result handling |
|---|---|---|
| Save | `POST /users/me/password` | 204 → toast "Password changed". 401 → "Current password is incorrect." 422 → policy errors. |

#### ACC-04 — Security
**Domain:** D2 Account & Security · **Route:** `/account/security` · **Archetype:** settings · **Phase:** MVP  
**Who:** Self  
**Purpose:** Password, two-factor and active sessions in one place.

**macOS container:** Detail column `Form(.grouped)`; per-row save

**Loads:** `GET /users/me` · `GET /users/me/sessions`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Password | row → ACC-03 | NavigationLink row |  | "Change…" |
| Two-factor authentication | toggle | Toggle(.switch) | User.mfa_enabled | on → ACC-05; off → ACC-06 |
| Sessions | list | List | Session[]: user_agent → device name/icon, ip_address → approximate location*, last_seen_at, current | current session marked "This device" and not revocable here |
| Sign out | row-button | trailing Button | DELETE /users/me/sessions/{sessionId} | per non-current row |
| Sign out of all other sessions | destructive-button | Button(role:.destructive) | DELETE /users/me/sessions/{sessionId} for each non-current session | confirm first; also stops push to those sessions' devices |
| Devices & notifications | row → NTF-03 | NavigationLink row | count of active devices |  |

| Action | Call | Result handling |
|---|---|---|
| Revoke one | `DELETE /users/me/sessions/{sessionId}` | Optimistic remove; 404 → already gone, refresh. |
| Sign out everywhere else | `DELETE each non-current session` | Confirm first (COPY). Uses per-session deletes so the current session survives; `logout all_sessions` signs this device out too. Revoking a session deactivates the push devices it registered. |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

**Notes:** *IP → city lookup is client-side/optional; never show the raw IP to non-staff unless the user expands details.

#### ACC-05 — Turn on two-factor
**Domain:** D2 Account & Security · **Route:** `/account/security/2fa/enable` · **Archetype:** wizard · **Phase:** MVP  
**Who:** Self  
**Purpose:** Enroll an authenticator app (TOTP) and save recovery codes.

**macOS container:** Sheet with paged steps + Back/Continue

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Step 1 — Scan | qr + secret | Image (CoreImage QR) | POST /users/me/mfa/totp → otpauth_uri, secret | QR, "Copy setup key", "Open in authenticator" (otpauth:// link on mobile) |
| Step 2 — Confirm | otp-field | TextField .textContentType(.oneTimeCode) | body.code | 6 digits |
| Step 3 — Recovery codes | secret-list | SecretReveal with code grid + Save/Print | confirm response: 10 recovery codes | Copy all · Save as file · Print · Share (mobile); "I've saved these" checkbox gates Done |

| Action | Call | Result handling |
|---|---|---|
| Start | `POST /users/me/mfa/totp` | 409 → already enabled; close. |
| Confirm | `POST /users/me/mfa/totp/confirm` | 200 → step 3. 401 → wrong code. |

**States:** Cancel before confirm leaves MFA off (no server cleanup needed)

#### ACC-06 — Turn off two-factor
**Domain:** D2 Account & Security · **Route:** `/account/security/2fa/disable` · **Archetype:** confirm · **Phase:** MVP  
**Who:** Self  
**Purpose:** Disable TOTP with a current code or a recovery code.

**macOS container:** `.confirmationDialog` or sheet when it has fields

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Code or recovery code | otp-field / text-field | TextField .textContentType(.oneTimeCode) | body.code \| body.recovery_code |  |
| Turn off | destructive-button | Button(role:.destructive) | POST /users/me/mfa/totp/disable |  |

| Action | Call | Result handling |
|---|---|---|
| Turn off | `POST /users/me/mfa/totp/disable` | 204 → toggle off, toast. 401 → wrong code. 429 → countdown. |

#### ACC-07 — Privacy
**Domain:** D2 Account & Security · **Route:** `/account/privacy` · **Archetype:** settings · **Phase:** MVP  
**Who:** Self  
**Purpose:** Export personal data and delete the account.

**macOS container:** Detail column `Form(.grouped)`; per-row save

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Download my data | button | Button .bordered | GET /users/me/export | saves `substratal-export-{date}.json` via save dialog / share sheet / download |
| Delete account | destructive-row → ACC-08 | Button(role:.destructive) row |  |  |

| Action | Call | Result handling |
|---|---|---|
| Download | `GET /users/me/export` | 200 → save file. 429 → "You can export again in {n} minutes." |

#### ACC-08 — Delete account
**Domain:** D2 Account & Security · **Route:** `/account/privacy/delete` · **Archetype:** confirm · **Phase:** MVP  
**Who:** Self  
**Purpose:** Request erasure: deactivate now, erase after 7 days.

**macOS container:** `.confirmationDialog` or sheet when it has fields

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| What happens | text | Text |  | list: signed out everywhere; apps lose access; erased on {date}; support can cancel within 7 days |
| Reason | text-area | TextEditor | body.reason | optional |
| Type DELETE | text-field | TextField | client only | must equal localized confirm word |
| Delete account | destructive-button | Button(role:.destructive) | POST /users/me/erasure-requests |  |

| Action | Call | Result handling |
|---|---|---|
| Delete account | `POST /users/me/erasure-requests` | 202 ErasureRequest → clear local data → AUTH-01 with "Your account is scheduled for deletion on {scheduled_for}." |

**Notes:** If the User has an individual Standard subscription, warn first and link to "Manage billing" (BIL-03) — erasure doesn't cancel Stripe billing by itself (see OPEN-QUESTIONS).


### D3 · My Apps

#### APP-01 — Home — My Apps
**Domain:** D3 My Apps · **Route:** `/` · **Archetype:** grid · **Phase:** MVP  
**Who:** Signed in  
**Purpose:** See every app you can use, its access status, and launch it.

**macOS container:** Detail column of `NavigationSplitView`, `LazyVGrid`

**Loads:** `GET /applications (catalog the caller can see; each has access_tier free|standard)` · `GET /users/me/entitlements?include_inactive=true` · `GET /users/me (plan, plan_sources)` · `GET /organizations?status=active (for pending invites)` · `GET /users/me/notifications/unread-count (bell badge)`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Search | search | .searchable(placement:.toolbar) | client filter on name/description |  |
| Grid / List | segmented | Picker(.segmented) | client preference | persisted locally |
| Invitation banner | banner | inline banner view | Organization.my_membership_status = pending | "{org} invited you…" → ORG-09 |
| Verify-email banner | banner | inline banner view | User.email_verified = false | → AUTH-04 |
| Your apps | tile-grid | LazyVGrid(adaptive:160) | join Application × resolved entitlement per app | tile = icon, name, StatusBadge, helper text (COPY); order: recently launched, then A–Z; pin to top (local) |
| Included with Standard | tile-grid | LazyVGrid(adaptive:160) | standard apps with status none while User.plan = free | badge "Standard"; tap → upsell sheet → BIL-01 ("Get every app for $6/month") |
| Available | tile-grid | LazyVGrid(adaptive:160) | other public apps with status none | "Get access" → app support/launch page |
| Inbox bell | toolbar button + count | .toolbar items | NotificationUnreadCount.unread_count | → NTF-01 |
| Past apps | row → APP-05 | NavigationLink row | count of revoked/expired |  |

| Action | Call | Result handling |
|---|---|---|
| Open tile (active) | `→ APP-03` | Launch |
| Open tile (other status) | `→ APP-02` | Explain status |
| Context menu / long-press |  | Open · App settings (SET-02) · App profile (APP-04) · Pin · Copy app ID |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; Empty: "No apps yet" (COPY); Offline: show cached grid read-only, launch disabled

**Notes:** Resolved status per app: if any path active → Active; else precedence scheduled > disabled > expired > revoked > none (matches the API's resolved_entitlement_status). Use `GET /users/me/apps/{appId}/effective-permissions` for the authoritative answer when the user opens APP-02. Tile helper text by source of the active path: `free` → "Free"; `subscription` → "Standard" (or "Standard · via {org}"); developer grants as before. The Hub itself (`app_hub`) is hidden from the grid.

#### APP-02 — App info
**Domain:** D3 My Apps · **Route:** `/apps/:appId` · **Archetype:** popover · **Phase:** MVP  
**Who:** Signed in  
**Purpose:** Explain an app's access status and how the user has access.

**macOS container:** `.popover` or `.inspector`

**Loads:** `GET /applications/{appId}` · `GET /users/me/apps/{appId}/effective-permissions`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Header | icon + name + StatusBadge | Image + Text + StatusBadge | Application, EffectivePermissions.entitlement_status |  |
| Description | text | Text | Application.description |  |
| Access paths | list | List | EffectivePermissions.access_paths[]: source, status, organization_id, member_decision | "Free", "Standard · your subscription", "Standard · via Northwind", "Personal · Granted by admin", "Via Northwind (seat)"; member_decision excluded explained |
| Tier | badge | StatusBadge (capsule Label) | Application.access_tier | "Free" or "Standard" |
| Your roles | chips | Toolbar Picker / token buttons | EffectivePermissions.roles | Phase 2 |
| Primary action | button | Button .bordered |  | Active → Open; none + standard tier + plan free → Upgrade to Standard (BIL-01); none+public → Get access; disabled/expired/revoked → Contact support (support_url) |
| Notification settings | row → SET-02 | NavigationLink row | resolved notifications.push for this app | "Push notifications: On/Off" |
| App settings / App profile | rows | List rows | → SET-02, APP-04 | only when active |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### APP-03 — Launch app
**Domain:** D3 My Apps · **Route:** `/apps/:appId/launch` · **Archetype:** handoff · **Phase:** MVP  
**Who:** Active entitlement  
**Purpose:** Mint an app token (or authorization code) and open the app.

**macOS container:** `NSWorkspace.open` / `ASWebAuthenticationSession`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Progress | inline spinner on tile | ProgressView on tile |  | ≤ 1 s target |
| Organization picker | menu | Menu | body.organization_id | only when the user has access via >1 org and the app needs org context |

| Action | Call | Result handling |
|---|---|---|
| Embedded launch | `POST /auth/app-tokens {application_id, organization_id?}` | 200 AppToken (5-min JWT) → open launch_url with token per app contract; never cache the token. 403 → refresh status, show APP-02. 409 → choose org. |
| Hosted launch (Phase 3) | `POST /auth/oauth/authorization-codes (PKCE S256)` | 201 → open redirect_uri?code=…&state=… in system browser / auth session |

**Notes:** Record launch time locally for "recently launched" ordering.

#### APP-04 — App profile
**Domain:** D3 My Apps · **Route:** `/apps/:appId/profile` · **Archetype:** form · **Phase:** Phase 2  
**Who:** Self, active entitlement  
**Purpose:** Per-app display handle and custom profile fields.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

**Loads:** `GET /users/me/apps/{appId}/profile`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Display handle | text-field | TextField | AppProfile.display_handle | optional; shows effective_display_name as placeholder |
| Custom fields | key/value editor | List of key/value TextField rows + Add | AppProfile.custom | only keys the app documents; null removes a key |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /users/me/apps/{appId}/profile (If-Match)` |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### APP-05 — Past apps
**Domain:** D3 My Apps · **Route:** `/apps/past` · **Archetype:** list · **Phase:** MVP  
**Who:** Signed in  
**Purpose:** Apps whose access was revoked or expired.

**macOS container:** Content column (`List(selection:)`) + detail column

**Loads:** `GET /users/me/entitlements?include_inactive=true&status=revoked,expired`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | List | Application name/icon, status, ended/revoked date, source | tap → APP-02 |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES


### D4 · Settings

#### SET-01 — Settings — Global
**Domain:** D4 Settings · **Route:** `/settings` · **Archetype:** settings · **Phase:** MVP  
**Who:** Self  
**Purpose:** Preferences that apply to every app.

**macOS container:** Detail column `Form(.grouped)`; per-row save

**Loads:** `GET /users/me/settings` · `GET /users/me/apps/app_hub/settings` (theme choices)

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Language | select | Picker(.menu) | Settings.locale | BCP 47; default = OS language; changing re-localizes the UI live |
| Time zone | searchable select | Picker + searchable popover | Settings.timezone | IANA; default = OS zone; "Use device time zone" option |
| Appearance | segmented | Picker(.segmented) | Settings.theme light/dark/system | Light · Dark · Match system (default). Applies instantly; Match system follows OS changes live. See THEMES.md |
| Light theme | swatch picker | LazyVGrid of swatch buttons (radio group, `.isSelected` trait) | `app_hub` setting `appearance.light_theme` | 5 themes: Teal Ledger, Indigo Root, **Moss Paper (default)**, Coral Clay, Spore Pink. Each swatch previews background, sidebar, accent and text; selected swatch shows a check and accent ring; accessible name "{name} light theme, selected" |
| Dark theme | swatch picker | LazyVGrid of swatch buttons (radio group, `.isSelected` trait) | `app_hub` setting `appearance.dark_theme` | 5 themes: Night Soil, Deep Teal, Graphite Spore, **Forest Floor (default)**, Ember Clay. Same behaviour as Light theme |
| Push notifications | toggle | Toggle(.switch) | Settings.notifications.push (missing = on) | global master switch for push from every app; inbox still fills (NTF-01). Shows OS permission state beneath: "Allowed in system settings" / "Blocked — Open system settings" |
| Email notifications | toggle | Toggle(.switch) | Settings.notifications.email |  |
| SMS notifications | toggle | Toggle(.switch) | Settings.notifications.sms | disabled until Profile.contact_phone set |
| Share usage analytics | toggle | Toggle(.switch) | client preference | default per platform policy; see CLIENT-ARCHITECTURE §10 |
| Scope list | list | List | Global + each app with active access | → SET-02 (desktop: middle column) |

| Action | Call | Result handling |
|---|---|---|
| Change any row | `PATCH /users/me/settings (If-Match)` | Optimistic; revert + toast on error. |
| Choose a light or dark theme | `PATCH /users/me/apps/app_hub/settings (If-Match)` with `appearance.light_theme` / `appearance.dark_theme` | Apply instantly, cache locally, then sync; 409 → refetch and re-apply; 422 → revert + toast "Couldn't save your theme. Try again." |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### SET-02 — Settings — Per app
**Domain:** D4 Settings · **Route:** `/settings/apps/:appId` · **Archetype:** settings · **Phase:** Phase 2  
**Who:** Self, active entitlement  
**Purpose:** Override global settings and set app-specific preferences from the app's settings_schema.

**macOS container:** Detail column `Form(.grouped)`; per-row save

**Loads:** `GET /applications/{appId} (settings_schema)` · `GET /users/me/apps/{appId}/settings`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Schema form | generated rows | SchemaForm | settings_schema properties × AppSettingsResolved.resolved | string+enum → select; string → text; string date-time → date-time picker; boolean → toggle; integer/number with min/max → stepper/number; object/array → read-only JSON preview |
| Source caption | caption per row | Text .font(.caption) .foregroundStyle(.secondary) | AppSettingsResolved.sources[key] | app_override → "Changed for this app · Reset"; global → "Using your global setting"; app_default → "App default" |
| Reset | link per overridden row | NavigationLink row | PATCH overrides {key:null} |  |
| Push notifications for this app | toggle | Toggle(.switch) | resolved notifications.push; override via overrides.notifications.push | source caption as other rows; Reset clears the override |
| Stale overrides | banner | inline banner view | AppSettingsResolved.stale_overrides | "Some saved settings no longer apply" → Clear |

| Action | Call | Result handling |
|---|---|---|
| Change row | `PATCH /users/me/apps/{appId}/settings {overrides:{key:value}} (If-Match)` | 422 → field error from details (enum_mismatch, out_of_range, undeclared_key). |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES


### D5 · Organizations

#### ORG-01 — Organizations
**Domain:** D5 Organizations · **Route:** `/orgs` · **Archetype:** list · **Phase:** MVP  
**Who:** Signed in (all orgs with organizations.manage)  
**Purpose:** Organizations you belong to, plus pending invitations.

**macOS container:** Content column (`List(selection:)`) + detail column

**Loads:** `GET /organizations`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Pending invitations | section | Section | my_membership_status = pending | Accept / Decline inline → ORG-09 |
| Rows | list | List | Organization: name, member_count, my_role, status | suspended orgs show badge |
| Search | search | .searchable(placement:.toolbar) | q | server-side when > 50 |
| New organization | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | → ORG-02 |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### ORG-02 — New organization
**Domain:** D5 Organizations · **Route:** `/orgs/new` · **Archetype:** form · **Phase:** MVP  
**Who:** Any active user  
**Purpose:** Create an organization; creator becomes org admin.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Name | text-field | TextField | body.name | required; ≤ 100 |

| Action | Call | Result handling |
|---|---|---|
| Create | `POST /organizations (Idempotency-Key)` | 201 → ORG-03. 429 → rate limited. |

#### ORG-03 — Organization — Members
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/members` · **Archetype:** detail · **Phase:** MVP  
**Who:** Member (read), org_admin / organizations.manage (write)  
**Purpose:** See and manage members and pending invitations.

**macOS container:** Detail column; tabs as segmented `Picker` in toolbar principal

**Loads:** `GET /organizations/{id}` · `GET /organizations/{id}/members`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Header | avatar + name + id-chip + status | Text(.monospaced) + copy Button | Organization | my_role shown as caption |
| Tabs | tabs | Picker(.segmented) in toolbar | Members · Apps & seats · Subscription · Activity · Settings | Subscription tab = BIL-02 (org_admin / billing.manage only) |
| Filter | chips | Toolbar Picker / token buttons | role, membership_status |  |
| Rows | list | List | OrganizationMember: display_name/email, role, membership_status, joined_at/invited_at | pending rows show Invited badge + invited date |
| Invite | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | → ORG-04 | org_admin only |

| Action | Call | Result handling |
|---|---|---|
| Row menu | `→ ORG-05` | Change role · Remove · Withdraw invitation |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### ORG-04 — Invite member
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/members/invite` · **Archetype:** form · **Phase:** MVP  
**Who:** org_admin; organizations.manage may add by user_id  
**Purpose:** Invite people by email (or add an existing user, staff only).

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Emails | token-field | TokenField (NSTokenField) | body.email (one request per address) | paste a list separated by commas/newlines |
| Role | radio-group | Picker(.radioGroup) | body.role org_admin \| member | default member |
| Add existing user | user-picker | searchable sheet with List | body.user_id | organizations.manage only |

| Action | Call | Result handling |
|---|---|---|
| Send invitations | `POST /organizations/{id}/members × n` | Per-address results summary; 409 → "already a member" per address. Responses look identical whether or not the address has an account (by design). |

**Notes:** Existing Users also receive an `account.organization_invitation` platform alert (NTF-01, and a push where the Hub has a device).

#### ORG-05 — Member actions
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/members/:userId` · **Archetype:** confirm · **Phase:** MVP  
**Who:** org_admin / organizations.manage  
**Purpose:** Change a member's role or remove them.

**macOS container:** `.confirmationDialog` or sheet when it has fields

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Role | radio-group | Picker(.radioGroup) | body.role |  |
| Remove from organization | destructive-button | Button(role:.destructive) | DELETE …/members/{userId} | COPY confirm |

| Action | Call | Result handling |
|---|---|---|
| Change role | `PATCH /organizations/{id}/members/{userId}` | 409 → last admin can't be demoted (explain). |
| Remove | `DELETE /organizations/{id}/members/{userId}` | 409 → last admin can't leave/remove. |

#### ORG-06 — Organization — Apps & seats
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/apps` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** Member (read), org_admin (write)  
**Purpose:** Org-wide app grants and who they include.

**macOS container:** Tab content inside detail

**Loads:** `GET /organizations/{id}/entitlements`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | List | Entitlement (org): app, status, source (org_seat \| subscription), member_scope, included_member_count, starts_at/ends_at | AccessSwitch for status (org_admin with rights); `subscription` rows read "Included with the organization's Standard subscription" and their scope is edited in BIL-02 |
| Grant app | button | Button .bordered | → ORG-07 |  |

| Action | Call | Result handling |
|---|---|---|
| Toggle | `PATCH /entitlements/{id} {status}` | Off requires confirm (COPY). On platform-managed rows only active ⇄ disabled is allowed; anything else 409 entitlement_platform_managed. |
| Edit scope | `→ ORG-07 (org_seat) / BIL-02 (subscription)` |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### ORG-07 — Grant app to organization / member scope
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/apps/grant` · **Archetype:** form · **Phase:** Phase 2  
**Who:** entitlements.manage or app owner; scope edits by org_admin  
**Purpose:** Create an org-wide grant or change who it includes.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| App | select | Picker(.menu) | body.application_id | create only |
| Who gets access | radio-group | Picker(.radioGroup) | member_scope all_members \| allowlist \| denylist | COPY labels |
| Members | member-picker | searchable sheet with List | member_overrides | visible for allowlist/denylist |
| Starts / Ends | date-fields | DatePicker(.field) | starts_at, ends_at | optional |
| Order ID | text-field | TextField | order_id | optional; staff/app owner |

| Action | Call | Result handling |
|---|---|---|
| Grant | `POST /organizations/{id}/entitlements (Idempotency-Key)` | 409 entitlement_already_exists → already granted (view it). 422 → field errors. |
| Save scope | `PATCH /entitlements/{id} {member_scope, member_overrides}` |  |

#### ORG-08 — Organization — Settings
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/settings` · **Archetype:** settings · **Phase:** MVP  
**Who:** org_admin (rename), organizations.manage (suspend), any member (leave)  
**Purpose:** Rename, leave, or (staff) suspend the organization.

**macOS container:** Detail column `Form(.grouped)`; per-row save

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Name | text-field | TextField | Organization.name |  |
| Leave organization | destructive-row | Button(role:.destructive) row | DELETE …/members/me |  |
| Suspend / Reactivate | destructive-row | Button(role:.destructive) row | PATCH {status, reason} | staff only; reason required |

| Action | Call | Result handling |
|---|---|---|
| Rename | `PATCH /organizations/{id} {name}` |  |
| Leave | `DELETE /organizations/{id}/members/me` | 409 last admin |

#### ORG-09 — Invitation
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/invitation` · **Archetype:** confirm · **Phase:** MVP  
**Who:** Invited user  
**Purpose:** Accept or decline an organization invitation.

**macOS container:** `.confirmationDialog` or sheet when it has fields

**Entry points:** Invitation banner on APP-01; ORG-01 pending section; `account.organization_invitation` notification (NTF-01 / push action "Accept")

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Org name + inviter | text | Text | Organization |  |
| Accept | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) |  |  |
| Decline | button | Button .bordered |  |  |

| Action | Call | Result handling |
|---|---|---|
| Accept | `POST /organizations/{id}/members/me/accept` | 200 → ORG-03; apps granted via org (and the org's Standard subscription, if it covers the member) appear on APP-01. Mark the related notification read. |
| Decline | `DELETE /organizations/{id}/members/me` |  |


### D6 · Users

#### USR-01 — Users
**Domain:** D6 Users · **Route:** `/admin/users` · **Archetype:** table · **Phase:** MVP  
**Who:** users.list (write actions need users.manage)  
**Purpose:** Find and manage user accounts.

**macOS container:** Detail column with `Table`, inspector optional

**Loads:** `GET /users?limit=50&cursor&status&q&application_id&organization_id&created_after&created_before`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Search | search | .searchable(placement:.toolbar) | q (name, email, ID prefix usr_) | debounced 300 ms; exact ID jumps to USR-03 |
| Status filter | chips | Toolbar Picker / token buttons | status active \| invited \| suspended \| deleted | deleted visible to users.manage only |
| More filters | menu | Menu | application_id, organization_id, created range |  |
| Columns | table | Table (sortable) | display name + email, status, id, last_login_at, created_at | sortable client-side within loaded page; server order = created_at desc |
| Multi-select | selection | Picker(.menu) |  | bulk: Suspend, Resend invitation, Export CSV (client-side of loaded rows) |
| Invite user | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | → USR-02 | users.manage |

| Action | Call | Result handling |
|---|---|---|
| Open row | `→ USR-03` |  |
| Row menu |  | Suspend · Reactivate · Resend invitation · Copy ID · Copy email |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### USR-02 — Invite user
**Domain:** D6 Users · **Route:** `/admin/users/new` · **Archetype:** form · **Phase:** MVP  
**Who:** users.manage  
**Purpose:** Create a user and send an invitation (or create active).

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Email | email-field | TextField .textContentType(.username) .keyboardType n/a | body.email | required |
| Display name | text-field | TextField | body.display_name | optional |
| Send invitation email | toggle | Toggle(.switch) | body.send_invitation | default on |
| Create as active | toggle | Toggle(.switch) | body.status active\|invited | default off (invited) |

| Action | Call | Result handling |
|---|---|---|
| Invite | `POST /users (Idempotency-Key)` | 201 → USR-03. 409 → "A user with that email exists" + link. |

#### USR-03 — User — Overview
**Domain:** D6 Users · **Route:** `/admin/users/:userId` · **Archetype:** detail · **Phase:** MVP  
**Who:** users.list (read), users.manage (write)  
**Purpose:** One user's account state and administrative actions.

**macOS container:** Detail column; tabs as segmented `Picker` in toolbar principal

**Loads:** `GET /users/{id}` · `GET /users/{id}/profile` · `GET /users/{id}/subscription (billing.manage only)`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Header | avatar, display name, email, id-chip, StatusBadge | Text(.monospaced) + copy Button | User + Profile |  |
| Tabs | tabs | Picker(.segmented) in toolbar | Overview · Access · Roles · Sessions · Notifications · Activity | Activity = AUD-01 filtered target_user_id; Notifications = NTF-05 |
| Facts | key-value list | LabeledContent rows | email_verified, pending_email, mfa_enabled, plan, plan_sources, created_at, last_login_at, signup_application_id, test_mode | plan_sources link to the org or to BIL-01 subscription view (billing.manage) |
| Actions | toolbar/menu | Menu |  | Suspend (USR-05) · Reactivate · Resend invitation · Reset two-factor · Export data · Delete · Restore · Cancel scheduled erasure |

| Action | Call | Result handling |
|---|---|---|
| Reactivate / Restore | `PATCH /users/{id} {status:active} (If-Match)` |  |
| Resend invitation | `POST /users/{id}/invitation` | 202 toast; 409 → not in invited state |
| Reset two-factor | `DELETE /users/{id}/mfa/totp` | confirm |
| Export | `GET /users/{id}/export` | save file |
| Delete | `DELETE /users/{id}` | confirm |
| Cancel erasure | `DELETE /users/{id}/erasure-requests/current` | visible during 7-day window |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### USR-04 — User — Sessions
**Domain:** D6 Users · **Route:** `/admin/users/:userId/sessions` · **Archetype:** tab · **Phase:** MVP  
**Who:** users.manage  
**Purpose:** See and revoke a user's sessions.

**macOS container:** Tab content inside detail

**Loads:** `GET /users/{id}/sessions`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | List | Session: user_agent, ip_address, amr, created_at, last_seen_at, expires_at | staff see full IP |

| Action | Call | Result handling |
|---|---|---|
| Revoke | `DELETE /users/{id}/sessions/{sessionId}` |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### USR-05 — Suspend user
**Domain:** D6 Users · **Route:** `/admin/users/:userId/suspend` · **Archetype:** confirm · **Phase:** MVP  
**Who:** users.manage  
**Purpose:** Suspend an account (signs them out everywhere).

**macOS container:** `.confirmationDialog` or sheet when it has fields

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Reason | text-area | TextEditor | body.reason | optional, written to audit |

| Action | Call | Result handling |
|---|---|---|
| Suspend | `POST /users/{id}/suspend` | 200 → badge Suspended. 409 → already suspended. |

**Notes:** Suspension revokes sessions and deactivates every push device the user holds; say so in the confirm body.


### D7 · Access

#### ACX-01 — User — Access
**Domain:** D7 Access · **Route:** `/admin/users/:userId/access` · **Archetype:** tab · **Phase:** MVP  
**Who:** users.list (read), entitlements.manage or app owner (write)  
**Purpose:** Turn apps on or off for one person; see every path that grants access.

**macOS container:** Tab content inside detail

**Loads:** `GET /users/{id}/entitlements?include_inactive=true` · `GET /applications`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Rows (one per app) | list | List | resolved status per app + paths (personal Entitlement, org grants) | StatusBadge + AccessSwitch on the personal path |
| Paths disclosure | expandable | DisclosureGroup | Entitlement.granted_via, source, subscription_id, organization_id, member_decision, starts_at/ends_at, disabled_reason | platform-managed paths (`free`, `subscription`) show a "Managed by Substratal" caption; developer grants and platform rows can coexist |
| Org-only access | caption + link | Text .font(.caption) .foregroundStyle(.secondary) |  | switch replaced by "Via {org}" → ORG-06 |
| Grant access | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | → ACX-02 |  |
| Why? | button | Button .bordered | → ACX-04 |  |

| Action | Call | Result handling |
|---|---|---|
| Switch off | `→ ACX-03 confirm → PATCH /entitlements/{id} {status:disabled, disabled_reason}` | Optimistic with Undo (PATCH status:active) for 10 s |
| Switch on | `PATCH /entitlements/{id} {status:active}` | No confirm |
| Remove grant made in error | `DELETE /entitlements/{id}` | Only developer grants with no order_id and < 24 h old; never on `free`/`subscription` rows (409 entitlement_platform_managed); 409 otherwise |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

**Notes:** The switch on a platform-managed row only disables / re-enables it (the platform never re-enables a row an admin disabled). Term, source and scope edits are hidden for those rows.

#### ACX-02 — Grant access
**Domain:** D7 Access · **Route:** `/admin/users/:userId/access/grant` · **Archetype:** form · **Phase:** MVP  
**Who:** entitlements.manage or app owner (own app)  
**Purpose:** Give a person access to an app.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| App | select | Picker(.menu) | body.application_id | app owners see only their apps |
| Type | radio-group | Picker(.radioGroup) | body.source purchase \| trial \| admin_grant | `free` and `subscription` are never offered — the platform grants them |
| Order ID | text-field | TextField | body.order_id | required feel for purchase |
| Starts | date-time | DatePicker(.field) | body.starts_at | default now |
| Ends | date-time | DatePicker(.field) | body.ends_at | optional; required for trial |

| Action | Call | Result handling |
|---|---|---|
| Grant | `POST /users/{id}/entitlements (Idempotency-Key, required)` | 201 → row Active. 409 entitlement_already_exists → "Already has access — view it". A platform-managed row doesn't block a grant (they coexist). |

#### ACX-03 — Turn off / edit access
**Domain:** D7 Access · **Route:** `/admin/entitlements/:entId` · **Archetype:** confirm · **Phase:** MVP  
**Who:** entitlements.manage or app owner  
**Purpose:** Confirm turning off access, or edit term/source.

**macOS container:** `.confirmationDialog` or sheet when it has fields

**Loads:** `GET /entitlements/{id}`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Title | text | Text |  | "Turn off {app} for {name}?" |
| Reason | text-field | TextField | body.disabled_reason | optional; audit |
| Edit term (advanced) | disclosure | DisclosureGroup | ends_at, source trial→purchase, order_id | hidden for platform-managed rows |

| Action | Call | Result handling |
|---|---|---|
| Turn off | `PATCH /entitlements/{id} {status:disabled, disabled_reason} (If-Match)` | 409 version_conflict → reload and re-apply |
| Save changes | `PATCH /entitlements/{id}` |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### ACX-04 — Access check (Why?)
**Domain:** D7 Access · **Route:** `/admin/users/:userId/apps/:appId/why` · **Archetype:** popover · **Phase:** MVP  
**Who:** users.list or app owner  
**Purpose:** Explain exactly why a person can or can't use an app.

**macOS container:** `.popover` or `.inspector`

**Loads:** `GET /users/{id}/apps/{appId}/effective-permissions`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Verdict | banner | inline banner view | allowed | "Dana can use Timetrack" / "can't" + first failing reason (user_status, entitlement_status) |
| Checks | checklist | List of Label with status symbols | user_status, entitlement_status, access_paths[], roles, effective_permissions | each with pass/fail icon |
| Computed at | caption | Text .font(.caption) .foregroundStyle(.secondary) | computed_at |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### ACX-05 — Access (all)
**Domain:** D7 Access · **Route:** `/admin/access` · **Archetype:** table · **Phase:** MVP  
**Who:** entitlements.manage or app owner (confined to own apps)  
**Purpose:** Every grant across users: audit expirations, trials, revocations.

**macOS container:** Detail column with `Table`, inspector optional

**Loads:** `GET /entitlements?application_id&status&source&user_id&organization_id&order_id&ends_before&include_inactive`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Filters | chips/menus | Toolbar Picker / token buttons | app, status, source, ends before (Expiring in 7/30 days presets) |  |
| Columns | table | Table (sortable) | user, app, status, source (incl. free / subscription), subscription_id, starts_at, ends_at, order_id, granted_via | source filter defaults to developer grants; "Show platform-managed" chip adds free/subscription rows |
| Multi-select | selection | Picker(.menu) |  | bulk Turn off (confirm, one PATCH per row, progress + per-row result) |

| Action | Call | Result handling |
|---|---|---|
| Open row | `→ ACX-01 for that user` |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES


### D8 · Roles & Permissions

#### ROL-01 — Roles
**Domain:** D8 Roles & Permissions · **Route:** `/admin/roles` · **Archetype:** list · **Phase:** MVP  
**Who:** users.list (read), roles.manage (write)  
**Purpose:** Platform and app roles.

**macOS container:** Content column (`List(selection:)`) + detail column

**Loads:** `GET /roles?scope` · `GET /permissions (includes notifications.send)`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Scope | segmented | Picker(.segmented) | scope platform \| {application_id} | app picker when App |
| Rows | list | List | Role: name, scope, seed, assignment_count, description | seed → lock icon |
| New role | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | → ROL-03 | roles.manage |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### ROL-02 — Role detail
**Domain:** D8 Roles & Permissions · **Route:** `/admin/roles/:roleId` · **Archetype:** detail · **Phase:** MVP  
**Who:** users.list (read), roles.manage (write)  
**Purpose:** View or edit a role's permissions.

**macOS container:** Detail column; tabs as segmented `Picker` in toolbar principal

**Loads:** `GET /roles/{id}` · `GET /permissions?scope`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Header | name, id-chip, scope, assignment_count | Text(.monospaced) + copy Button |  |  |
| Description | text-field | TextField | Role.description | read-only when seed |
| Permissions | checkbox-list grouped by prefix | List of Toggle(.checkbox), grouped Sections | Role.permissions ⊂ Permission[].key | read-only when seed; show Permission.description |
| Holders | link | Button .link | → USR-01 filtered | Phase 2 if API adds filter; else hidden |
| Delete role | destructive-button | Button(role:.destructive) | DELETE /roles/{id} | only non-seed with assignment_count 0 |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /roles/{id} {description, permissions} (If-Match)` |  |
| Delete | `DELETE /roles/{id}` | 409 → still assigned |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### ROL-03 — New role
**Domain:** D8 Roles & Permissions · **Route:** `/admin/roles/new` · **Archetype:** form · **Phase:** MVP  
**Who:** roles.manage  
**Purpose:** Define a custom role.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Name | text-field | TextField | body.name | ^[a-z][a-z0-9_]{1,40}$; live validation |
| Scope | select | Picker(.menu) | body.scope | platform or an application |
| Description | text-area | TextEditor | body.description |  |
| Permissions | checkbox-list | List of Toggle(.checkbox), grouped Sections | body.permissions | ≥ 1 |

| Action | Call | Result handling |
|---|---|---|
| Create | `POST /roles` | 409 → name taken in scope |

#### ROL-04 — User — Roles
**Domain:** D8 Roles & Permissions · **Route:** `/admin/users/:userId/roles` · **Archetype:** tab · **Phase:** MVP  
**Who:** users.list (read), roles.manage (write)  
**Purpose:** Assign and remove roles for a user.

**macOS container:** Tab content inside detail

**Loads:** `GET /users/{id}/roles`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | List | UserRoleAssignment: role, application_id, assigned_at, assigned_by, implicit | implicit → "Granted with app access", not removable |
| Assign role | button → role picker | searchable sheet with List |  |  |

| Action | Call | Result handling |
|---|---|---|
| Assign | `POST /users/{id}/roles/{roleId}` | idempotent; 200 already held |
| Remove | `DELETE /users/{id}/roles/{roleId}` | 409 → implicit role |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES


### D9 · Applications

#### APL-01 — Applications
**Domain:** D9 Applications · **Route:** `/dev/apps` · **Archetype:** list · **Phase:** MVP  
**Who:** Any (catalog), owner/applications.manage (manage)  
**Purpose:** Applications you own or administer.

**macOS container:** Content column (`List(selection:)`) + detail column

**Loads:** `GET /applications?owned=true (staff: all, filter review_status)`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | List | ApplicationSummary: icon, name, slug, visibility, review_status |  |
| Register app | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | → APL-02 | applications.manage (Phase 3: self-service) |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### APL-02 — Register application
**Domain:** D9 Applications · **Route:** `/dev/apps/new` · **Archetype:** wizard · **Phase:** MVP  
**Who:** applications.manage  
**Purpose:** Register a new application.

**macOS container:** Sheet with paged steps + Back/Continue

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Name | text-field | TextField | name |  |
| Slug | text-field | TextField | slug | ^[a-z][a-z0-9-]{1,63}$; 'platform' reserved; immutable — warn |
| Launch URL | url-field | TextField .textContentType(.URL) | launch_url | https |
| Redirect URIs | url-list | List of TextField + Add/Remove | redirect_uris |  |
| Visibility | radio-group | Picker(.radioGroup) | visibility public \| invite_only \| internal |  |
| Access tier | radio-group | Picker(.radioGroup) | access_tier free \| standard (default standard) | applications.manage only; explains who gets access |
| Owner | select | Picker(.menu) | owner_user_id \| owner_organization_id |  |
| Icon | image-picker | PhotosPicker + fileImporter + paste/drop | icon_url |  |
| Support URL | url-field | TextField .textContentType(.URL) | support_url |  |
| Email from name | text-field | TextField | email_from_name |  |

| Action | Call | Result handling |
|---|---|---|
| Register | `POST /applications (Idempotency-Key)` | 409 slug taken; 422 field errors. Registration grants the platform-managed Entitlements its access_tier calls for. |

#### APL-03 — Application — Overview
**Domain:** D9 Applications · **Route:** `/dev/apps/:appId` · **Archetype:** detail · **Phase:** MVP  
**Who:** Owner (configure), applications.manage (anything)  
**Purpose:** Configure an application.

**macOS container:** Detail column; tabs as segmented `Picker` in toolbar principal

**Loads:** `GET /applications/{id}`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Header | icon, name, id-chip, review StatusBadge | Text(.monospaced) + copy Button |  |  |
| Tabs | tabs | Picker(.segmented) in toolbar | Overview · Settings schema · Permissions & roles · Push · Keys · Webhooks · Access · Activity | Push = APL-07; Keys/Webhooks/Access = KEY-01/WHK-01/ACX-05 filtered to app |
| Fields | settings rows | Form rows | name, description, launch_url, redirect_uris, support_url, email_from_name, icon_url (owner); visibility, access_tier (applications.manage) | slug read-only with lock |
| Platform alerts | toggle | Toggle(.switch) | receives_platform_notifications | owner-writable; explain that security/account alerts will push under this app's name and icon |
| Review notes | banner | inline banner view | review_status rejected/suspended + review_notes |  |

| Action | Call | Result handling |
|---|---|---|
| Save field | `PATCH /applications/{id} (If-Match)` | 422 → field errors |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### APL-04 — Settings schema
**Domain:** D9 Applications · **Route:** `/dev/apps/:appId/schema` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** Owner / applications.manage  
**Purpose:** Declare per-user settings the app stores in Substratal.

**macOS container:** Tab content inside detail

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Properties | list | List | settings_schema.properties: key, type, enum, default, min/max, maxLength | add/edit via form |
| JSON editor | code-editor | TextEditor monospaced + validation | settings_schema | JSON validation; diff before save |
| Preview | rendered SET-02 form | SchemaForm preview |  | shows exactly what users will see |
| Stats | caption | Text .font(.caption) .foregroundStyle(.secondary) | settings_schema_stats |  |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /applications/{id} {settings_schema}` | 422 → schema rule errors |

#### APL-05 — Permissions & roles
**Domain:** D9 Applications · **Route:** `/dev/apps/:appId/roles` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** Owner / applications.manage  
**Purpose:** Declare app permissions, available app roles and the default role.

**macOS container:** Tab content inside detail

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Permissions | editable list | Table (sortable) | permissions[] {key, description} | key app.<slug>.* |
| Available app roles | token list | TokenField (NSTokenField) | available_app_roles (≤ 20) |  |
| Default app role | select | Picker(.menu) | default_app_role | held implicitly by everyone with access |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /applications/{id}` |  |

#### APL-06 — Review
**Domain:** D9 Applications · **Route:** `/dev/apps/:appId/review` · **Archetype:** form · **Phase:** Phase 3  
**Who:** applications.manage  
**Purpose:** Staff moderation of an application.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Review status | radio-group | Picker(.radioGroup) | review_status |  |
| Notes | text-area | TextEditor | review_notes | visible to owner |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /applications/{id} {review_status, review_notes}` |  |

#### APL-07 — Push credentials
**Domain:** D9 Applications · **Route:** `/dev/apps/:appId/push` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** Owner or applications.manage with a User token (write); app key / owner (read)  
**Purpose:** Configure APNs, FCM and Web Push for an Application so it can send push notifications.

**macOS container:** Tab content inside detail

**Loads:** `GET /applications/{id}/push-credentials`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Providers | list | List | PushCredential per provider apns \| fcm \| web_push: status active\|failed, failure_reason, failed_at, last_used_at; apns team_id/key_id/topic; fcm project_id/client_email; web_push subject + vapid_public_key | one row per provider with Add / Edit / Remove; failed → warning banner with reason and Fix |
| Add APNs | form | sheet with Form | POST body: provider apns, team_id (10), key_id (10), topic (bundle id), private_key (.p8) | file picker / drop / paste for the .p8; key never shown again |
| Add FCM | form | sheet with Form | POST body: provider fcm, service_account (JSON file) | drop/pick the downloaded JSON; validate shape client-side |
| Add Web Push | form | sheet with Form | POST body: provider web_push, subject (mailto:/https:) | platform generates VAPID keys; show vapid_public_key with Copy for the web client |
| Rotate / fix | form | sheet with Form | PATCH /applications/{id}/push-credentials/{provider} {key material \| status: active} | web_push can't rotate in place — explain delete + recreate |
| Remove | destructive-button | Button(role:.destructive) | DELETE /applications/{id}/push-credentials/{provider} | confirm; web_push removal deactivates every browser subscription |
| Platform alerts | toggle | Toggle(.switch) | Application.receives_platform_notifications | mirror of APL-03 toggle |

| Action | Call | Result handling |
|---|---|---|
| Add | `POST /applications/{id}/push-credentials` | 201; 409 push_credential_exists → Edit; 422 invalid_push_credential → field error; 400 user_token_required never happens in the Hub (User token) |
| Fetch one | `GET /applications/{id}/push-credentials/{provider}` | on Edit |
| Save | `PATCH /applications/{id}/push-credentials/{provider}` | 409 invalid_status_transition |
| Remove | `DELETE /applications/{id}/push-credentials/{provider}` | 204 |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

**Notes:** To prove a credential end to end, the owner registers a device of their own app and uses its device test (NTF-03 lists only devices the owner holds).


### D10 · Developer Credentials

#### KEY-01 — API keys
**Domain:** D10 Developer Credentials · **Route:** `/dev/keys` · **Archetype:** table · **Phase:** MVP  
**Who:** api_keys.manage or app owner  
**Purpose:** Service and agent credentials.

**macOS container:** Detail column with `Table`, inspector optional

**Loads:** `GET /api-keys?scope&mode&intended_use&include_inactive`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Filters | chips | Toolbar Picker / token buttons | mode live\|test, intended_use, include revoked | TEST pill on test keys |
| Columns | table | Table (sortable) | name, scope, secret_hint, mode, intended_use, restrict_destructive, last_used_at, expires_at, status(revoked_at) |  |
| Create key | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | → KEY-02 |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### KEY-02 — Create API key
**Domain:** D10 Developer Credentials · **Route:** `/dev/keys/new` · **Archetype:** form · **Phase:** MVP  
**Who:** api_keys.manage or app owner (app-scoped)  
**Purpose:** Create a key with least-privilege permissions.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Name | text-field | TextField | name |  |
| Scope | select | Picker(.menu) | scope platform \| application | owners: own apps only |
| Mode | segmented | Picker(.segmented) | mode live \| test | fixed after creation — say so |
| Used by | radio-group | Picker(.radioGroup) | intended_use service \| agent | agent defaults restrict_destructive on |
| Block destructive operations | toggle | Toggle(.switch) | restrict_destructive |  |
| Permissions | checkbox-list | List of Toggle(.checkbox), grouped Sections | permissions | filtered by scope via GET /permissions; app-scoped keys offer the five app-confined platform permissions incl. notifications.send |
| Expires | date-field | DatePicker(.field) | expires_at | optional; presets 30/90/365 days |

| Action | Call | Result handling |
|---|---|---|
| Create | `POST /api-keys` | 201 ApiKeyWithSecret → KEY-03 |

#### KEY-03 — Secret reveal
**Domain:** D10 Developer Credentials · **Route:** `(modal)` · **Archetype:** secret · **Phase:** MVP  
**Who:** Creator  
**Purpose:** Show a new API key secret, webhook signing secret, or recovery codes exactly once.

**macOS container:** Sheet, `interactiveDismissDisabled`, `sharingType = .none`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Secret | monospace selectable text | Text(.monospaced).textSelection(.enabled) | secret | never logged, never persisted, excluded from screenshots where the OS allows |
| Copy | button | Button .bordered | clipboard (sensitive) | auto-clear after 60 s where supported |
| Acknowledge | checkbox | Toggle(.checkbox) | client | gates Done; modal not dismissible until checked |

#### KEY-04 — API key detail
**Domain:** D10 Developer Credentials · **Route:** `/dev/keys/:keyId` · **Archetype:** detail · **Phase:** MVP  
**Who:** api_keys.manage or app owner  
**Purpose:** Edit, rotate or revoke a key.

**macOS container:** Detail column; tabs as segmented `Picker` in toolbar principal

**Loads:** `GET /api-keys/{id}` · `GET /audit-events?actor_id={id}`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Fields | settings rows | Form rows | name, permissions, restrict_destructive (editable); scope, mode, intended_use, expires_at, created_by, last_used_at (read-only) |  |
| Rotate | button | Button .bordered | POST /api-keys/{id}/rotate | confirm: no overlap |
| Revoke | destructive-button | Button(role:.destructive) | DELETE /api-keys/{id} | confirm |
| Recent activity | list | List | audit events by this key |  |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /api-keys/{id}` |  |
| Rotate | `POST …/rotate` | 200 → KEY-03 |
| Revoke | `DELETE /api-keys/{id}` | 204 → row shows Revoked |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### WHK-01 — Webhooks
**Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks` · **Archetype:** list · **Phase:** Phase 2  
**Who:** webhooks.manage or app owner  
**Purpose:** Endpoints that receive access-change events.

**macOS container:** Content column (`List(selection:)`) + detail column

**Loads:** `GET /webhooks?application_id`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | List | Webhook: url, events count, status healthy\|unhealthy\|disabled, consecutive_failures, last_delivery_at, scope |  |
| Add webhook | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | → WHK-02 |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### WHK-02 — Add / edit webhook
**Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks/new` · **Archetype:** form · **Phase:** Phase 2  
**Who:** webhooks.manage or app owner  
**Purpose:** Subscribe a URL to events.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| URL | url-field | TextField .textContentType(.URL) | url | https required |
| Events | checkbox-list grouped | List of Toggle(.checkbox), grouped Sections | events (WebhookEvent enum: access.*, entitlement.*, role.*, user.*, organization.*, application.*, device.*, notification.push_failed, push_credential.failed, webhook.test) | presets: Access changes (access.granted, access.revoked) · Push health (notification.push_failed, push_credential.failed, device.deactivated) |
| Description | text-field | TextField | description |  |
| Scope | select | Picker(.menu) | scope |  |
| API version | select | Picker(.menu) | api_version | edit only |
| Enabled | toggle | Toggle(.switch) | status disabled ↔ healthy | edit only (re-enable) |

| Action | Call | Result handling |
|---|---|---|
| Create | `POST /webhooks` | 201 WebhookWithSecret → KEY-03 |
| Save | `PATCH /webhooks/{id}` |  |

#### WHK-03 — Webhook — Deliveries
**Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks/:hookId` · **Archetype:** detail · **Phase:** Phase 2  
**Who:** webhooks.manage or app owner  
**Purpose:** Delivery history, testing and secret rotation.

**macOS container:** Detail column; tabs as segmented `Picker` in toolbar principal

**Loads:** `GET /webhooks/{id}` · `GET /webhooks/{id}/deliveries?status&since&event_id`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Header actions | toolbar | .toolbar items |  | Send test · Rotate secret · Edit · Delete |
| Filter | chips | Toolbar Picker / token buttons | status pending\|succeeded\|failed |  |
| Rows | table | Table (sortable) | WebhookDelivery: status, response_status, event_type, event_id, attempt, duration_ms, created_at, next_retry_at, error | failed → Redeliver |

| Action | Call | Result handling |
|---|---|---|
| Send test | `POST /webhooks/{id}/test` | 202 → new pending row appears on refresh (poll 2 s × 5) |
| Rotate secret | `POST /webhooks/{id}/rotate-secret` | confirm (24 h overlap) → KEY-03 |
| Redeliver | `POST /webhooks/{id}/deliveries/{deliveryId}/redeliver` | 202 toast; same event_id |
| Delete | `DELETE /webhooks/{id}` | confirm |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### WHK-04 — Delivery detail
**Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks/:hookId/deliveries/:deliveryId` · **Archetype:** popover · **Phase:** Phase 2  
**Who:** webhooks.manage or app owner  
**Purpose:** One delivery attempt in detail.

**macOS container:** `.popover` or `.inspector`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Facts | key-value | LabeledContent rows | all WebhookDelivery fields |  |
| Redeliver | button | Button .bordered |  |  |


### D11 · Plan & Billing

#### BIL-01 — My plan
**Domain:** D11 Plan & Billing · **Route:** `/billing` · **Archetype:** settings · **Phase:** Phase 2  
**Who:** Self; billing.manage can view any User's (read-only)  
**Purpose:** Free or Standard: what covers you, your own subscription, upgrade and manage billing.

**macOS container:** Detail column `Form(.grouped)`; per-row save

**Loads:** `GET /users/me/subscription`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Plan card | card | GroupBox | UserSubscription.plan free\|standard | Free: "Free apps and services"; Standard: "Every app and service" |
| Covered by | list | List | plan_sources[]: {type user\|organization, id} | "Your subscription" / "Northwind Survey Co." (→ ORG-03); both listed when doubly covered |
| Double coverage tip | banner | inline banner view | plan_sources has both user and organization | "Your organization already covers you. You can cancel your own subscription in Manage billing." (no automatic refund) |
| Your subscription | key-value | LabeledContent rows | Subscription.status, current_period_end, cancel_at_period_end, unit_amount, currency | status badge via status-semantics; past_due → warning banner (Stripe retries ~3 weeks) |
| Upgrade to Standard | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | → BIL-03 checkout | when no covering subscription, or own subscription is incomplete/incomplete_expired/canceled/unpaid; price "$6 per month" from unit_amount when known, else from pricing config; store flags on iOS/Android |
| Manage billing | button | Button .bordered | → BIL-03 portal | when the user has completed a checkout (own subscription exists and isn't incomplete) |
| What Standard includes | link | Button .link | standard apps from GET /applications?access_tier=standard | count + list |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; After return from checkout: poll GET /users/me/subscription every 2 s (max 30 s) until plan = standard, showing "Finishing up…"; Downgraded: "Your Standard access ended on {date}. Your data is kept."

**Notes:** Business/Enterprise aren't offered (422 plan_requires_sales); show "Contact sales" link only. Annual plans and a Standard trial are undecided in the API (DECISIONS #31) — no UI for them.

#### BIL-02 — Organization subscription
**Domain:** D11 Plan & Billing · **Route:** `/orgs/:orgId/subscription` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** org_admin (manage); billing.manage (read + portal)  
**Purpose:** An organization's Standard subscription and which members it covers.

**macOS container:** Tab content inside detail

**Loads:** `GET /organizations/{id}/subscription` · `GET /organizations/{id}/members (for the member picker)`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Plan card | card | GroupBox | OrganizationSubscription.plan + Subscription.status, current_period_end, cancel_at_period_end |  |
| Billed users | key-value | LabeledContent rows | quantity.current (live), quantity.last_synced + last_synced_at, unit_amount | "31 covered · 30 billed on next invoice (synced daily)" |
| Who's covered | radio-group | Picker(.radioGroup) | member_scope all_members \| allowlist \| denylist | COPY labels |
| Members | member-picker | searchable sheet with List | member_overrides (active members only) | visible for allowlist/denylist |
| Subscribe the organization | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | → BIL-03 org checkout (member_scope optional) | org_admin; when no subscription or not in force |
| Manage billing | button | Button .bordered | → BIL-03 org portal | org_admin or billing.manage |

| Action | Call | Result handling |
|---|---|---|
| Save coverage | `PATCH /organizations/{id}/subscription {member_scope, member_overrides} (If-Match)` | effective immediately (access.granted/revoked); billed from next daily sync. 422 member_override_not_a_member → mark rows. 404 subscription_not_found → show Subscribe. |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

**Notes:** Members gained or lost take effect at once; say so before saving.

#### BIL-03 — Checkout / billing portal
**Domain:** D11 Plan & Billing · **Route:** `/billing/return` · **Archetype:** handoff · **Phase:** Phase 2  
**Who:** Self (individual); org_admin (org checkout); org_admin or billing.manage (org portal)  
**Purpose:** Hand off to Stripe in the browser and confirm the result on return.

**macOS container:** `NSWorkspace.open` / `ASWebAuthenticationSession`

| Action | Call | Result handling |
|---|---|---|
| Upgrade (individual) | `POST /users/me/billing/checkout-sessions {plan:standard, success_url, cancel_url} (Idempotency-Key)` | 201 {url, expires_at} → open url externally. 409 subscription_exists → open portal instead. 409 test_mode_billing_unavailable / 503 billing_unavailable → explain. |
| Manage billing (individual) | `POST /users/me/billing/portal-sessions {return_url}` | 201 → open url externally. 409 no_billing_account → hide button. |
| Subscribe organization | `POST /organizations/{id}/billing/checkout-sessions {plan:standard, success_url, cancel_url, member_scope?, member_overrides?} (Idempotency-Key)` | as individual |
| Manage organization billing | `POST /organizations/{id}/billing/portal-sessions {return_url}` | as individual |
| Return | `deep link /billing/return?checkout=success\|cancelled (or /orgs/:orgId/subscription?…)` | success: poll the matching GET …/subscription until plan = standard (coverage starts when Stripe's webhook lands, usually seconds); cancelled: toast "Checkout cancelled" |

**Notes:** success_url / cancel_url / return_url must be https: use https://dashboard.substratalapps.com/billing/return… (Universal/App Links open the native app).

#### BIL-04 — Tenant & infrastructure
**Domain:** D11 Plan & Billing · **Route:** `/dev/tenant` · **Archetype:** detail · **Phase:** Phase 2  
**Who:** Tenant owner (read), tenants.manage (write)  
**Purpose:** Where an Application owner's data lives, and support-run tier changes. Tenants aren't billed.

**macOS container:** Detail column; tabs as segmented `Picker` in toolbar principal

**Loads:** `GET /tenants` · `GET /tenants/{id}` · `GET /tenants/{id}/tier-change-requests`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Facts | key-value | LabeledContent rows | Tenant: tier, region, status, owner, application_count | status migrating → banner, writes may 503 |
| Tier change requests | list | List | TenantTierChangeRequest: from→requested tier/region, status, scheduled_for, reason, notes |  |
| Request change | button | Button .bordered | → form: requested_tier, requested_region, reason | tenants.manage only; owners see "isolated and dedicated-region tiers come with business pricing — contact sales" |

| Action | Call | Result handling |
|---|---|---|
| Open request | `POST /tenants/{id}/tier-change-requests` | 422/409 errors |
| Open request detail | `GET /tenants/{id}/tier-change-requests/{requestId}` | polls every 30 s while visible |
| Advance request | `PATCH /tenants/{id}/tier-change-requests/{requestId} {status, scheduled_for, notes} (If-Match)` | staff only; status scheduled → in_progress → completed, or cancelled |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES


### D12 · Audit Log

#### AUD-01 — Audit log
**Domain:** D12 Audit Log · **Route:** `/admin/audit` · **Archetype:** table · **Phase:** MVP  
**Who:** audit.view  
**Purpose:** Who changed what, when.

**macOS container:** Detail column with `Table`, inspector optional

**Loads:** `GET /audit-events?since&until&actor_type&actor_id&action&target_type&target_id&target_user_id&application_id&organization_id&tenant_id`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Filters | search + menus | .searchable(placement:.toolbar) | time range (presets 1h/24h/7d/custom), actor type, action (grouped by prefix, wildcard), resource | filters encoded in URL/state for sharing |
| Rows | table | Table (sortable) | timestamp, actor (type icon + name/id), action, target (type + link), app/org | relative time with absolute on hover/long-press |
| Inspector | side panel | .inspector | → AUD-02 | desktop: trailing pane; compact: push |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

**Notes:** Hot window is one year for every Tenant; read the `Audit-Hot-Window-Start` response header and show it in an info caption. Export is Phase 3. New actions to group in the filter: subscription.*, push_credential.*, device.deleted.

#### AUD-02 — Audit event
**Domain:** D12 Audit Log · **Route:** `/admin/audit/:eventId` · **Archetype:** popover · **Phase:** MVP  
**Who:** audit.view  
**Purpose:** One event with before/after diff.

**macOS container:** `.popover` or `.inspector`

**Loads:** `GET /audit-events/{id}`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Facts | key-value | LabeledContent rows | action, actor, target, target_user_id, application_id, organization_id, tenant_id, request_id, timestamp | each id links to its screen |
| Diff | diff view | Text(.monospaced) with +/− rows | before / after | JSON diff, changed keys highlighted |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES


### D13 · Notifications

#### NTF-01 — Inbox
**Domain:** D13 Notifications · **Route:** `/inbox` · **Archetype:** list · **Phase:** Phase 2  
**Who:** Self  
**Purpose:** Every notification the person has received — from every app and from Substratal — with read and archived state.

**macOS container:** Content column (`List(selection:)`) + detail column

**Entry points:** Bell in the toolbar / Inbox in the sidebar (desktop) / Inbox tab (compact); Tapping a push; Badges (app icon, Dock/taskbar, PWA)

**Loads:** `GET /users/me/notifications?state&application_id&source&category&priority&created_after&created_before&include_inactive` · `GET /users/me/notifications/unread-count` · `GET /applications (names, icons)`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Filter | segmented | Picker(.segmented) | Unread · All · Archived (state) | default All (unarchived) |
| App filter | menu | Menu | application_id, source=platform ("Substratal") | per-app unread counts from unread-count.applications[] |
| Rows | list | List | Notification: app icon (or Substratal mark for source=platform), title, body (2 lines), category, priority high → "Important" marker, created_at relative, unread dot | grouped Today / Yesterday / Earlier; high priority security.* rows show a shield icon |
| Mark all as read | button | Button .bordered | POST /users/me/notifications/mark-read {before: newest loaded created_at, application_id?} | respects current app filter; uses `before` so items arriving now aren't swept |
| Multi-select | selection | Picker(.menu) |  | bulk Mark read / Mark unread / Archive (one PATCH per row) |
| Row actions | row-button | trailing Button | PATCH /users/me/notifications/{notificationId} {state} | swipe/hover: Read ⇄ Unread, Archive |

| Action | Call | Result handling |
|---|---|---|
| Open row | `→ NTF-02 and PATCH {state:read}` | optimistic; badge decrements |
| Archive | `PATCH /users/me/notifications/{notificationId} {state:archived}` | toast with Undo (PATCH state:read) |
| Mark read / unread | `PATCH /users/me/notifications/{notificationId} {state}` |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; Empty: "You're all caught up"; Feature off (501 not_implemented): hide Inbox entry points; badges fall back to none; Retention: items older than 90 days disappear (caption in empty Archived view)

**Notes:** The Inbox shows notifications from every Application plus platform alerts, but push banners for another app's notifications arrive through that app's own install — the Hub only pushes its own (`app_hub`) notifications and platform alerts.

#### NTF-02 — Notification
**Domain:** D13 Notifications · **Route:** `/inbox/:notificationId` · **Archetype:** popover · **Phase:** Phase 2  
**Who:** Self (users.list via NTF-05)  
**Purpose:** One notification in full, with where it leads and how its push went.

**macOS container:** `.popover` or `.inspector`

**Loads:** `GET /users/me/notifications/{notificationId}`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Header | icon + app name + time | Image + Text + StatusBadge | application_id / source, created_at |  |
| Title & body | text | Text | title, body | full text |
| Open | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | action_url | Hub route → navigate in-app; https → open in browser / installed app (Universal/App Link); private scheme → hand to OS; hidden when null |
| Contextual actions | buttons | Button .bordered | by category | security.new_sign_in → "Review sessions" (ACC-04) · security.password_changed / email_changed / mfa_disabled / session_reuse_detected → "Secure my account" (ACC-04) · account.organization_invitation → Accept / Decline (ORG-09) · account.access_granted → Open app (APP-03) |
| Delivery | disclosure | DisclosureGroup | push.status, devices_targeted/sent/failed, skip_reason, deliveries[] (30 days) | plain-language: "Pushed to 2 devices", "Not pushed — notifications are off for this app" |
| Mark unread / Archive | toolbar | .toolbar items | PATCH {state} |  |

| Action | Call | Result handling |
|---|---|---|
| Opened | `PATCH /users/me/notifications/{notificationId} {state:read}` | on open if unread |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### NTF-03 — Devices & notifications
**Domain:** D13 Notifications · **Route:** `/account/devices` · **Archetype:** settings · **Phase:** Phase 2  
**Who:** Self  
**Purpose:** Where push notifications reach this person, and controls for this device.

**macOS container:** Detail column `Form(.grouped)`; per-row save

**Loads:** `GET /users/me/devices?include_inactive=true` · `GET /users/me/settings (notifications.push)`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| This device | card | GroupBox | Hub Device for this install (matched by stored device id) | status, "Notifications are on" / off / blocked by OS; button Turn on (→ NTF-04) or Turn off |
| Push notifications (all apps) | toggle | Toggle(.switch) | Settings.notifications.push | same control as SET-01 |
| Devices | list | List | Device: name, application_id (app icon), provider, environment, status, inactive_reason, last_registered_at, last_delivery_at, token_hint | grouped by app; inactive rows dimmed with reason ("Signed out", "Not opened in 9 months"…) |
| Rename | row-button | trailing Button | PATCH /users/me/devices/{deviceId} {name} |  |
| Send test notification | row-button | trailing Button | POST /users/me/devices/{deviceId}/test | Hub devices only; shows provider result inline (sent / failed + error) within 10 s |
| Remove | row-button | trailing Button | DELETE /users/me/devices/{deviceId} | confirm; that install stops receiving pushes until it registers again |

| Action | Call | Result handling |
|---|---|---|
| Turn off on this device | `DELETE /users/me/devices/{deviceId}` | and stop re-registering until the user turns it back on |
| Test | `POST /users/me/devices/{deviceId}/test` | 429 → "Try again later (10 tests per hour)"; 409 push_not_configured → "Push isn't set up for this app yet" |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

**Notes:** Device tokens are never displayed (only token_hint).

#### NTF-04 — Turn on notifications
**Domain:** D13 Notifications · **Route:** `/account/devices/enable` · **Archetype:** wizard · **Phase:** Phase 2  
**Who:** Self  
**Purpose:** Explain, request the OS permission, and register this install for push.

**macOS container:** Sheet with paged steps + Back/Continue

**Entry points:** First time a notification-worthy moment happens (org invite, first launch after sign-in on mobile, enabling in NTF-03/SET-01) — never on cold start before sign-in

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Primer | text + illustration | Text |  | what you'll get: security alerts, invitations, access changes; "You can change this anytime" |
| Turn on | primary-button | Button .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) | OS permission prompt | platform API per PLATFORM-SPEC |
| Not now | button | Button .bordered |  | snooze 14 days |

| Action | Call | Result handling |
|---|---|---|
| Permission granted | `POST /users/me/devices {application_id: app_hub, provider, token \| subscription, environment (apns), name, app_version}` | 201 new / 200 refresh → store device id; 403 entitlement_required → Hub access disabled for this user: show inbox-only mode; 422 invalid_device_token → log + retry next launch |
| Token rotated by OS | `PATCH /users/me/devices/{deviceId} {token \| subscription}` |  |
| Permission denied | `—` | Settings shows "Blocked — Open system settings" deep link |

**Notes:** Re-register on launch at most once a day (keeps the device from going stale after 270 days). Windows and Linux have no supported push provider in the API: this screen is replaced by an in-app explanation and local notifications from polling (see their PLATFORM-SPEC).

#### NTF-05 — User — Notifications & devices
**Domain:** D13 Notifications · **Route:** `/admin/users/:userId/notifications` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** users.list (read), users.manage (delete devices)  
**Purpose:** Support view of a person's inbox and push devices for investigations.

**macOS container:** Tab content inside detail

**Loads:** `GET /users/{id}/notifications?include_inactive=true` · `GET /users/{id}/notifications/{notificationId}` · `GET /users/{id}/devices?include_inactive=true` · `GET /users/{id}/devices/{deviceId}`

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Inbox | table | Table (sortable) | Notification: created_at, application/source, category, priority, title, state, push.status, skip_reason | read-only; never mark the user's items read |
| Delivery detail | side panel | .inspector | deliveries[]: provider, status, attempts, error, sent_at |  |
| Devices | table | Table (sortable) | Device: app, provider, environment, status, inactive_reason, name, app_version, last_registered_at, last_delivery_at |  |
| Delete device | destructive-button | Button(role:.destructive) | DELETE /users/{id}/devices/{deviceId} | users.manage; audited (device.deleted) |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

#### NTF-06 — Send notification
**Domain:** D13 Notifications · **Route:** `/admin/notifications/new` · **Archetype:** form · **Phase:** Phase 2  
**Who:** platform notifications.send  
**Purpose:** Staff tool: send one notification to one person on behalf of an Application.

**macOS container:** `.sheet` with `Form(.grouped)`; ⌘↩ submits, Esc cancels

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Recipient | picker | searchable sheet with List | user (GET /users?q) | must have active access to the chosen app |
| Application | select | Picker(.menu) | body.application_id | required for platform senders |
| Category | text-field | TextField | body.category | ^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$; security./account./billing. reserved (422 reserved_category) |
| Priority | segmented | Picker(.segmented) | body.priority normal \| high | high explains provider throttling |
| Title | text-field | TextField | body.title | 1–120 |
| Body | text-area | TextEditor | body.body | ≤ 1,000 |
| Data | key/value editor | List of key/value TextField rows + Add | body.data | ≤ 20 string pairs, 2 KB; key `substratal` reserved |
| Open link | url-field | TextField .textContentType(.URL) | body.action_url | https or private scheme ≤ 2,048 |
| Advanced | disclosure | DisclosureGroup | push (bool), badge, sound default\|silent, collapse_key, ttl_seconds |  |
| Payload size | meters | Gauge(.linearCapacity) | client estimate of the 4 KB push payload | 422 notification_too_large → details.bytes/max |
| Preview | card | GroupBox | lock-screen style preview per platform | warns: lock screens show title/body — keep sensitive content out |

| Action | Call | Result handling |
|---|---|---|
| Send | `POST /users/{id}/notifications (Idempotency-Key)` | 201 → result panel with push.status / skip_reason; 403 entitlement_required → recipient has no access; 429 scope sender\|recipient → countdown |


### G · Global / Shell

#### GLB-01 — Command palette
**Domain:** G Global / Shell · **Route:** `(overlay)` · **Archetype:** overlay · **Phase:** MVP  
**Who:** Signed in  
**Purpose:** Jump anywhere and run any action by typing.

**macOS container:** Floating panel / toolbar popover

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Query | search | .searchable(placement:.toolbar) |  | Cmd/Ctrl+K (desktop), search button (touch) |
| Results | grouped list | List with Sections | Navigation, Apps (launch), Users (GET /users?q), Orgs, Inbox (recent notifications), Actions (Invite user, Create key, Upgrade to Standard, Mark all read…) | permission-filtered; recent items first; ID prefixes resolve directly |

#### GLB-02 — Manage hub
**Domain:** G Global / Shell · **Route:** `/manage` · **Archetype:** hub · **Phase:** MVP  
**Who:** Any admin or developer permission  
**Purpose:** Compact-layout landing list for the Administration and Developer sections.

**macOS container:** — (sidebar replaces hubs)

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Administration | list | List | Users, Access, Roles, Audit Log, Send notification — those the caller may open, each with a live count | section hidden when no rights |
| Developer | list | List | Applications, API Keys, Webhooks, Tenant — with counts and health (unhealthy webhooks, failed push credentials) | section hidden when no rights |

#### GLB-03 — System states
**Domain:** G Global / Shell · **Route:** `(global)` · **Archetype:** overlay · **Phase:** MVP  
**Who:** —  
**Purpose:** App-wide banners and blocking states.

**macOS container:** Floating panel / toolbar popover

| Element | Kind | macOS control | Data / API | Rules |
|---|---|---|---|---|
| Offline | banner | inline banner view | network status | reads from cache, writes disabled with explanation |
| Maintenance / tenant migrating | banner | inline banner view | 503 service_unavailable + Retry-After | countdown, auto-retry reads |
| Session ended | modal → AUTH-01 | Alert → AUTH-01 | 401 after failed refresh | preserve destination |
| Forbidden | inline state | ProgressView on tile | 403 | COPY |
| Not built yet | inline state | ProgressView on tile | 501 not_implemented (Notifications today) | hide the feature behind its flag; never show an error to end users |
| Update required | blocking screen | full-window view | API version deprecation header (if present) |  |

| Action | Call | Result handling |
|---|---|---|
| Silent token refresh | `POST /auth/token/refresh` | single-flight; rotate refresh token atomically (CLIENT-ARCHITECTURE §2) |
| Connectivity probe | `GET /health` | used after offline/503 to decide when to clear the banner |
| Forced sign-out | `POST /auth/logout` | on session_revoked or user request; the server deactivates this session's push devices |
| Badge refresh | `GET /users/me/notifications/unread-count` | on launch, foreground, after a push arrives, and every 60 s while visible (Windows/Linux: also in background) → app icon/Dock/taskbar/PWA badge |

**Notes:** `POST /auth/oauth/token` is called by the launched Application (PKCE exchange), never by the Hub.
