Docs 00-shared SCREEN-SPECS.md
Screen Specs
Every screen in the Substratal Hub, identical across all six apps. Platform folders contain the same specs with native controls filled in (SCREENS.md).
Element kinds are defined in COMPONENTS.md. Archetypes: auth Unauthenticated full-screen form, outside the app shell; grid Top-level collection rendered as tiles; list Top-level searchable/filterable collection (rows); table Top-level dense collection with columns (desktop), rows (compact); detail Object detail with header + tabs/segments; tab A tab/segment inside a detail screen; form Create/edit form presented modally (sheet/dialog); settings Grouped settings form, edits save per-row; confirm Destructive or consequential confirmation (alert/dialog), optional reason field; secret One-time secret reveal, cannot be dismissed until acknowledged; wizard Multi-step modal flow; popover Lightweight anchored or half-height panel for explanation; handoff Leaves the app (browser / other app) and handles the return; overlay App-wide overlay (palette, inbox panel); hub Mobile hub list linking to sections.
Universal rules for every screen (not repeated below): Capabilities gating (INFORMATION-ARCHITECTURE §2), request conventions — Idempotency-Key on creates, If-Match on PATCH/DELETE, cursor pagination, error decoding (CLIENT-ARCHITECTURE §3), copy (COPY-AND-STATES), accessibility (ACCESSIBILITY).
Index
| ID | Screen | Domain | Route | Archetype | Phase |
|---|---|---|---|---|---|
| AUTH-01 | Sign in | D1 | /sign-in |
auth | MVP |
| AUTH-02 | Two-factor challenge | D1 | /sign-in/mfa |
auth | MVP |
| AUTH-03 | Create account | D1 | /sign-up |
auth | MVP |
| AUTH-04 | Verify your email | D1 | /verify-email |
auth | MVP |
| AUTH-05 | Forgot password | D1 | /forgot-password |
auth | MVP |
| AUTH-06 | Set a new password | D1 | /reset-password |
auth | MVP |
| AUTH-07 | Accept invitation | D1 | /invite |
auth | MVP |
| ACC-01 | Account & profile | D2 | /account |
settings | MVP |
| ACC-02 | Change email | D2 | /account/email |
form | MVP |
| ACC-03 | Change password | D2 | /account/security/password |
form | MVP |
| ACC-04 | Security | D2 | /account/security |
settings | MVP |
| ACC-05 | Turn on two-factor | D2 | /account/security/2fa/enable |
wizard | MVP |
| ACC-06 | Turn off two-factor | D2 | /account/security/2fa/disable |
confirm | MVP |
| ACC-07 | Privacy | D2 | /account/privacy |
settings | MVP |
| ACC-08 | Delete account | D2 | /account/privacy/delete |
confirm | MVP |
| APP-01 | Home — My Apps | D3 | / |
grid | MVP |
| APP-02 | App info | D3 | /apps/:appId |
popover | MVP |
| APP-03 | Launch app | D3 | /apps/:appId/launch |
handoff | MVP |
| APP-04 | App profile | D3 | /apps/:appId/profile |
form | Phase 2 |
| APP-05 | Past apps | D3 | /apps/past |
list | MVP |
| SET-01 | Settings — Global | D4 | /settings |
settings | MVP |
| SET-02 | Settings — Per app | D4 | /settings/apps/:appId |
settings | Phase 2 |
| ORG-01 | Organizations | D5 | /orgs |
list | MVP |
| ORG-02 | New organization | D5 | /orgs/new |
form | MVP |
| ORG-03 | Organization — Members | D5 | /orgs/:orgId/members |
detail | MVP |
| ORG-04 | Invite member | D5 | /orgs/:orgId/members/invite |
form | MVP |
| ORG-05 | Member actions | D5 | /orgs/:orgId/members/:userId |
confirm | MVP |
| ORG-06 | Organization — Apps & seats | D5 | /orgs/:orgId/apps |
tab | Phase 2 |
| ORG-07 | Grant app to organization / member scope | D5 | /orgs/:orgId/apps/grant |
form | Phase 2 |
| ORG-08 | Organization — Settings | D5 | /orgs/:orgId/settings |
settings | MVP |
| ORG-09 | Invitation | D5 | /orgs/:orgId/invitation |
confirm | MVP |
| USR-01 | Users | D6 | /admin/users |
table | MVP |
| USR-02 | Invite user | D6 | /admin/users/new |
form | MVP |
| USR-03 | User — Overview | D6 | /admin/users/:userId |
detail | MVP |
| USR-04 | User — Sessions | D6 | /admin/users/:userId/sessions |
tab | MVP |
| USR-05 | Suspend user | D6 | /admin/users/:userId/suspend |
confirm | MVP |
| ACX-01 | User — Access | D7 | /admin/users/:userId/access |
tab | MVP |
| ACX-02 | Grant access | D7 | /admin/users/:userId/access/grant |
form | MVP |
| ACX-03 | Turn off / edit access | D7 | /admin/entitlements/:entId |
confirm | MVP |
| ACX-04 | Access check (Why?) | D7 | /admin/users/:userId/apps/:appId/why |
popover | MVP |
| ACX-05 | Access (all) | D7 | /admin/access |
table | MVP |
| ROL-01 | Roles | D8 | /admin/roles |
list | MVP |
| ROL-02 | Role detail | D8 | /admin/roles/:roleId |
detail | MVP |
| ROL-03 | New role | D8 | /admin/roles/new |
form | MVP |
| ROL-04 | User — Roles | D8 | /admin/users/:userId/roles |
tab | MVP |
| APL-01 | Applications | D9 | /dev/apps |
list | MVP |
| APL-02 | Register application | D9 | /dev/apps/new |
wizard | MVP |
| APL-03 | Application — Overview | D9 | /dev/apps/:appId |
detail | MVP |
| APL-04 | Settings schema | D9 | /dev/apps/:appId/schema |
tab | Phase 2 |
| APL-05 | Permissions & roles | D9 | /dev/apps/:appId/roles |
tab | Phase 2 |
| APL-06 | Review | D9 | /dev/apps/:appId/review |
form | Phase 3 |
| KEY-01 | API keys | D10 | /dev/keys |
table | MVP |
| KEY-02 | Create API key | D10 | /dev/keys/new |
form | MVP |
| KEY-03 | Secret reveal | D10 | (modal) |
secret | MVP |
| KEY-04 | API key detail | D10 | /dev/keys/:keyId |
detail | MVP |
| WHK-01 | Webhooks | D10 | /dev/webhooks |
list | Phase 2 |
| WHK-02 | Add / edit webhook | D10 | /dev/webhooks/new |
form | Phase 2 |
| WHK-03 | Webhook — Deliveries | D10 | /dev/webhooks/:hookId |
detail | Phase 2 |
| WHK-04 | Delivery detail | D10 | /dev/webhooks/:hookId/deliveries/:deliveryId |
popover | Phase 2 |
| BIL-01 | My plan | D11 | /billing |
settings | Phase 2 |
| BIL-02 | Organization subscription | D11 | /orgs/:orgId/subscription |
tab | Phase 2 |
| BIL-03 | Checkout / billing portal | D11 | /billing/return |
handoff | Phase 2 |
| BIL-04 | Tenant & infrastructure | D11 | /dev/tenant |
detail | Phase 2 |
| AUD-01 | Audit log | D12 | /admin/audit |
table | MVP |
| AUD-02 | Audit event | D12 | /admin/audit/:eventId |
popover | MVP |
| NTF-01 | Inbox | D13 | /inbox |
list | Phase 2 |
| NTF-02 | Notification | D13 | /inbox/:notificationId |
popover | Phase 2 |
| NTF-03 | Devices & notifications | D13 | /account/devices |
settings | Phase 2 |
| NTF-04 | Turn on notifications | D13 | /account/devices/enable |
wizard | Phase 2 |
| NTF-05 | User — Notifications & devices | D13 | /admin/users/:userId/notifications |
tab | Phase 2 |
| NTF-06 | Send notification | D13 | /admin/notifications/new |
form | Phase 2 |
| APL-07 | Push credentials | D9 | /dev/apps/:appId/push |
tab | Phase 2 |
| GLB-01 | Command palette | G | (overlay) |
overlay | MVP |
| GLB-02 | Manage hub | G | /manage |
hub | MVP |
| GLB-03 | System states | G | (global) |
overlay | MVP |
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.
Entry points: App launch with no stored session; Session expired / revoked (preserves ?next= destination); Sign out
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| email-field | body.email | required; RFC 5322; trim; autofill username | |
| Password | password-field | body.password | required; reveal toggle; autofill current-password |
| Sign in | primary-button | POST /auth/login | disabled until both fields non-empty |
| Forgot password? | link | → AUTH-05 | |
| Create an account | link | → AUTH-03 | |
| Continue with SSO | button | POST /auth/sso/{provider}/callback | hidden behind flag sso (API returns 501 today) |
| Sign in with a passkey | button | — | 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.
Entry points: AUTH-01 returned MfaChallenge
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Code | otp-field | body.code | 6 digits; paste fills all; auto-submit on 6th digit; OS one-time-code autofill |
| Use a recovery code | link | swaps to Recovery code field | only if methods includes recovery_code |
| Recovery code | text-field | body.recovery_code | format xxxx-xxxx; case-insensitive |
| Expiry countdown | caption | MfaChallenge.expires_in | announce at 60 s and 10 s |
| Verify | primary-button | 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.
Entry points: AUTH-01 link; Marketing/app deep link with optional application_id
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Display name | text-field | body.display_name | optional; ≤ 100 |
| email-field | body.email | required | |
| Password | password-field | body.password | required; strength meter; autofill new-password; show policy hint |
| Create account | primary-button | POST /auth/signup | |
| Terms / Privacy | links | 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.
Entry points: After signup; Persistent banner on APP-01 while email_verified is false; Deep link /verify-email?token=…
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Instruction | text | User.email | "We sent a link to {email}." |
| Resend email | button | POST /auth/email/verify/resend | 30 s cooldown after each send |
| Continue to Substratal | button | → APP-01 | unverified users may continue |
| Change email | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| email-field | body.email | prefilled from AUTH-01 | |
| Send reset link | primary-button | 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.
Entry points: Deep link /reset-password?token=…
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| New password | password-field | body.new_password | strength meter; autofill new-password |
| Confirm password | password-field | client only | must match |
| Notice | caption | "You'll be signed out on all devices." | |
| Save password | primary-button | 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.
Entry points: Deep link /invite?token=… from the invitation email
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Display name | text-field | body.display_name | optional |
| Password | password-field | body.password | strength meter |
| Accept and continue | primary-button | 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.
Loads: GET /users/me · GET /users/me/profile
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Avatar | image-picker | Profile.avatar_url | camera / photo library / file / paste; client uploads to app asset storage then PATCHes URL* |
| Display name | text-field | Profile.display_name | required; ≤ 100; save on blur/Return |
| row → ACC-02 | User.email + email_verified + pending_email | shows "Pending: {pending_email}" when set | |
| User ID | id-chip | User.id | copy |
| Contact email | email-field | Profile.contact_email | optional |
| Contact phone | phone-field | Profile.contact_phone | optional; E.164 formatting by locale |
| Plan | row → BIL-01 | User.plan free|standard + plan_sources | "Free" or "Standard · via {org}" / "Standard · your subscription" |
| Member since / last sign-in | caption | User.created_at, last_login_at | locale formatted |
| Sign out | button | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| New email | email-field | body.email | required; differs from current |
| Save | primary-button | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Current password | password-field | body.current_password | autofill current-password |
| New password | password-field | body.new_password | strength meter; autofill new-password |
| Save | primary-button | 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.
Loads: GET /users/me · GET /users/me/sessions
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Password | row → ACC-03 | "Change…" | |
| Two-factor authentication | toggle | User.mfa_enabled | on → ACC-05; off → ACC-06 |
| Sessions | 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 | DELETE /users/me/sessions/{sessionId} | per non-current row |
| Sign out of all other sessions | destructive-button | DELETE /users/me/sessions/{sessionId} for each non-current session | confirm first; also stops push to those sessions' devices |
| Devices & notifications | row → NTF-03 | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Step 1 — Scan | qr + secret | POST /users/me/mfa/totp → otpauth_uri, secret | QR, "Copy setup key", "Open in authenticator" (otpauth:// link on mobile) |
| Step 2 — Confirm | otp-field | body.code | 6 digits |
| Step 3 — Recovery codes | secret-list | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Code or recovery code | otp-field / text-field | body.code | body.recovery_code | |
| Turn off | destructive-button | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Download my data | button | GET /users/me/export | saves substratal-export-{date}.json via save dialog / share sheet / download |
| Delete account | destructive-row → ACC-08 |
| 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| What happens | text | list: signed out everywhere; apps lose access; erased on {date}; support can cancel within 7 days | |
| Reason | text-area | body.reason | optional |
| Type DELETE | text-field | client only | must equal localized confirm word |
| Delete account | destructive-button | 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.
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 | Data / API | Rules |
|---|---|---|---|
| Search | search | client filter on name/description | |
| Grid / List | segmented | client preference | persisted locally |
| Invitation banner | banner | Organization.my_membership_status = pending | "{org} invited you…" → ORG-09 |
| Verify-email banner | banner | User.email_verified = false | → AUTH-04 |
| Your apps | tile-grid | 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 | 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 | other public apps with status none | "Get access" → app support/launch page |
| Inbox bell | toolbar button + count | NotificationUnreadCount.unread_count | → NTF-01 |
| Past apps | row → APP-05 | 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.
Loads: GET /applications/{appId} · GET /users/me/apps/{appId}/effective-permissions
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Header | icon + name + StatusBadge | Application, EffectivePermissions.entitlement_status | |
| Description | text | Application.description | |
| Access paths | 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 | Application.access_tier | "Free" or "Standard" |
| Your roles | chips | EffectivePermissions.roles | Phase 2 |
| Primary action | button | 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 | resolved notifications.push for this app | "Push notifications: On/Off" |
| App settings / App profile | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Progress | inline spinner on tile | ≤ 1 s target | |
| Organization picker | 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.
Loads: GET /users/me/apps/{appId}/profile
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Display handle | text-field | AppProfile.display_handle | optional; shows effective_display_name as placeholder |
| Custom fields | key/value editor | 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.
Loads: GET /users/me/entitlements?include_inactive=true&status=revoked,expired
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Rows | 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.
Loads: GET /users/me/settings · GET /users/me/apps/app_hub/settings (theme choices)
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Language | select | Settings.locale | BCP 47; default = OS language; changing re-localizes the UI live |
| Time zone | searchable select | Settings.timezone | IANA; default = OS zone; "Use device time zone" option |
| Appearance | 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 | 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 | 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 | 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 | Settings.notifications.email | |
| SMS notifications | toggle | Settings.notifications.sms | disabled until Profile.contact_phone set |
| Share usage analytics | toggle | client preference | default per platform policy; see CLIENT-ARCHITECTURE §10 |
| Scope 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.
Loads: GET /applications/{appId} (settings_schema) · GET /users/me/apps/{appId}/settings
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Schema form | generated rows | 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 | AppSettingsResolved.sources[key] | app_override → "Changed for this app · Reset"; global → "Using your global setting"; app_default → "App default" |
| Reset | link per overridden row | PATCH overrides {key:null} | |
| Push notifications for this app | toggle | resolved notifications.push; override via overrides.notifications.push | source caption as other rows; Reset clears the override |
| Stale overrides | banner | 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.
Loads: GET /organizations
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Pending invitations | section | my_membership_status = pending | Accept / Decline inline → ORG-09 |
| Rows | list | Organization: name, member_count, my_role, status | suspended orgs show badge |
| Search | search | q | server-side when > 50 |
| New organization | primary-button | → 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Name | text-field | 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.
Loads: GET /organizations/{id} · GET /organizations/{id}/members
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Header | avatar + name + id-chip + status | Organization | my_role shown as caption |
| Tabs | tabs | Members · Apps & seats · Subscription · Activity · Settings | Subscription tab = BIL-02 (org_admin / billing.manage only) |
| Filter | chips | role, membership_status | |
| Rows | list | OrganizationMember: display_name/email, role, membership_status, joined_at/invited_at | pending rows show Invited badge + invited date |
| Invite | primary-button | → 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).
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Emails | token-field | body.email (one request per address) | paste a list separated by commas/newlines |
| Role | radio-group | body.role org_admin | member | default member |
| Add existing user | user-picker | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Role | radio-group | body.role | |
| Remove from organization | destructive-button | 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.
Loads: GET /organizations/{id}/entitlements
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Rows | 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 | → 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| App | select | body.application_id | create only |
| Who gets access | radio-group | member_scope all_members | allowlist | denylist | COPY labels |
| Members | member-picker | member_overrides | visible for allowlist/denylist |
| Starts / Ends | date-fields | starts_at, ends_at | optional |
| Order ID | text-field | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Name | text-field | Organization.name | |
| Leave organization | destructive-row | DELETE …/members/me | |
| Suspend / Reactivate | 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.
Entry points: Invitation banner on APP-01; ORG-01 pending section; account.organization_invitation notification (NTF-01 / push action "Accept")
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Org name + inviter | text | Organization | |
| Accept | primary-button | ||
| Decline | button |
| 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.
Loads: GET /users?limit=50&cursor&status&q&application_id&organization_id&created_after&created_before
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Search | search | q (name, email, ID prefix usr_) | debounced 300 ms; exact ID jumps to USR-03 |
| Status filter | chips | status active | invited | suspended | deleted | deleted visible to users.manage only |
| More filters | menu | application_id, organization_id, created range | |
| Columns | table | display name + email, status, id, last_login_at, created_at | sortable client-side within loaded page; server order = created_at desc |
| Multi-select | selection | bulk: Suspend, Resend invitation, Export CSV (client-side of loaded rows) | |
| Invite user | primary-button | → 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).
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| email-field | body.email | required | |
| Display name | text-field | body.display_name | optional |
| Send invitation email | toggle | body.send_invitation | default on |
| Create as active | toggle | 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.
Loads: GET /users/{id} · GET /users/{id}/profile · GET /users/{id}/subscription (billing.manage only)
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Header | avatar, display name, email, id-chip, StatusBadge | User + Profile | |
| Tabs | tabs | Overview · Access · Roles · Sessions · Notifications · Activity | Activity = AUD-01 filtered target_user_id; Notifications = NTF-05 |
| Facts | key-value list | 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 | 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.
Loads: GET /users/{id}/sessions
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Rows | 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).
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Reason | text-area | 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.
Loads: GET /users/{id}/entitlements?include_inactive=true · GET /applications
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Rows (one per app) | list | resolved status per app + paths (personal Entitlement, org grants) | StatusBadge + AccessSwitch on the personal path |
| Paths disclosure | expandable | 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 | switch replaced by "Via {org}" → ORG-06 | |
| Grant access | primary-button | → ACX-02 | |
| Why? | button | → 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| App | select | body.application_id | app owners see only their apps |
| Type | radio-group | body.source purchase | trial | admin_grant | free and subscription are never offered — the platform grants them |
| Order ID | text-field | body.order_id | required feel for purchase |
| Starts | date-time | body.starts_at | default now |
| Ends | date-time | 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.
Loads: GET /entitlements/{id}
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Title | text | "Turn off {app} for {name}?" | |
| Reason | text-field | body.disabled_reason | optional; audit |
| Edit term (advanced) | disclosure | 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.
Loads: GET /users/{id}/apps/{appId}/effective-permissions
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Verdict | banner | allowed | "Dana can use Timetrack" / "can't" + first failing reason (user_status, entitlement_status) |
| Checks | checklist | user_status, entitlement_status, access_paths[], roles, effective_permissions | each with pass/fail icon |
| Computed at | caption | 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.
Loads: GET /entitlements?application_id&status&source&user_id&organization_id&order_id&ends_before&include_inactive
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Filters | chips/menus | app, status, source, ends before (Expiring in 7/30 days presets) | |
| Columns | table | 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 | 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.
Loads: GET /roles?scope · GET /permissions (includes notifications.send)
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Scope | segmented | scope platform | {application_id} | app picker when App |
| Rows | list | Role: name, scope, seed, assignment_count, description | seed → lock icon |
| New role | primary-button | → 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.
Loads: GET /roles/{id} · GET /permissions?scope
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Header | name, id-chip, scope, assignment_count | ||
| Description | text-field | Role.description | read-only when seed |
| Permissions | checkbox-list grouped by prefix | Role.permissions ⊂ Permission[].key | read-only when seed; show Permission.description |
| Holders | link | → USR-01 filtered | Phase 2 if API adds filter; else hidden |
| Delete role | destructive-button | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Name | text-field | body.name | ^[a-z][a-z0-9_]{1,40}$; live validation |
| Scope | select | body.scope | platform or an application |
| Description | text-area | body.description | |
| Permissions | checkbox-list | 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.
Loads: GET /users/{id}/roles
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Rows | list | UserRoleAssignment: role, application_id, assigned_at, assigned_by, implicit | implicit → "Granted with app access", not removable |
| Assign role | button → role picker |
| 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.
Loads: GET /applications?owned=true (staff: all, filter review_status)
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Rows | list | ApplicationSummary: icon, name, slug, visibility, review_status | |
| Register app | primary-button | → 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Name | text-field | name | |
| Slug | text-field | slug | ^[a-z][a-z0-9-]{1,63}$; 'platform' reserved; immutable — warn |
| Launch URL | url-field | launch_url | https |
| Redirect URIs | url-list | redirect_uris | |
| Visibility | radio-group | visibility public | invite_only | internal | |
| Access tier | radio-group | access_tier free | standard (default standard) | applications.manage only; explains who gets access |
| Owner | select | owner_user_id | owner_organization_id | |
| Icon | image-picker | icon_url | |
| Support URL | url-field | support_url | |
| Email from name | text-field | 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.
Loads: GET /applications/{id}
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Header | icon, name, id-chip, review StatusBadge | ||
| Tabs | tabs | 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 | 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 | receives_platform_notifications | owner-writable; explain that security/account alerts will push under this app's name and icon |
| Review notes | banner | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Properties | list | settings_schema.properties: key, type, enum, default, min/max, maxLength | add/edit via form |
| JSON editor | code-editor | settings_schema | JSON validation; diff before save |
| Preview | rendered SET-02 form | shows exactly what users will see | |
| Stats | caption | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Permissions | editable list | permissions[] {key, description} | key app. |
| Available app roles | token list | available_app_roles (≤ 20) | |
| Default app role | select | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Review status | radio-group | review_status | |
| Notes | text-area | 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.
Loads: GET /applications/{id}/push-credentials
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Providers | 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 | 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 | POST body: provider fcm, service_account (JSON file) | drop/pick the downloaded JSON; validate shape client-side |
| Add Web Push | 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 | PATCH /applications/{id}/push-credentials/{provider} {key material | status: active} | web_push can't rotate in place — explain delete + recreate |
| Remove | destructive-button | DELETE /applications/{id}/push-credentials/{provider} | confirm; web_push removal deactivates every browser subscription |
| Platform alerts | toggle | 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.
Loads: GET /api-keys?scope&mode&intended_use&include_inactive
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Filters | chips | mode live|test, intended_use, include revoked | TEST pill on test keys |
| Columns | table | name, scope, secret_hint, mode, intended_use, restrict_destructive, last_used_at, expires_at, status(revoked_at) | |
| Create key | primary-button | → 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Name | text-field | name | |
| Scope | select | scope platform | application | owners: own apps only |
| Mode | segmented | mode live | test | fixed after creation — say so |
| Used by | radio-group | intended_use service | agent | agent defaults restrict_destructive on |
| Block destructive operations | toggle | restrict_destructive | |
| Permissions | checkbox-list | permissions | filtered by scope via GET /permissions; app-scoped keys offer the five app-confined platform permissions incl. notifications.send |
| Expires | date-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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Secret | monospace selectable text | secret | never logged, never persisted, excluded from screenshots where the OS allows |
| Copy | button | clipboard (sensitive) | auto-clear after 60 s where supported |
| Acknowledge | 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.
Loads: GET /api-keys/{id} · GET /audit-events?actor_id={id}
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Fields | settings rows | name, permissions, restrict_destructive (editable); scope, mode, intended_use, expires_at, created_by, last_used_at (read-only) | |
| Rotate | button | POST /api-keys/{id}/rotate | confirm: no overlap |
| Revoke | destructive-button | DELETE /api-keys/{id} | confirm |
| Recent activity | 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.
Loads: GET /webhooks?application_id
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Rows | list | Webhook: url, events count, status healthy|unhealthy|disabled, consecutive_failures, last_delivery_at, scope | |
| Add webhook | primary-button | → 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| URL | url-field | url | https required |
| Events | checkbox-list grouped | 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 | description | |
| Scope | select | scope | |
| API version | select | api_version | edit only |
| Enabled | toggle | 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.
Loads: GET /webhooks/{id} · GET /webhooks/{id}/deliveries?status&since&event_id
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Header actions | toolbar | Send test · Rotate secret · Edit · Delete | |
| Filter | chips | status pending|succeeded|failed | |
| Rows | table | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Facts | key-value | all WebhookDelivery fields | |
| Redeliver | button |
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.
Loads: GET /users/me/subscription
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Plan card | card | UserSubscription.plan free|standard | Free: "Free apps and services"; Standard: "Every app and service" |
| Covered by | list | plan_sources[]: {type user|organization, id} | "Your subscription" / "Northwind Survey Co." (→ ORG-03); both listed when doubly covered |
| Double coverage tip | banner | 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 | 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 | → 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 | → BIL-03 portal | when the user has completed a checkout (own subscription exists and isn't incomplete) |
| What Standard includes | 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.
Loads: GET /organizations/{id}/subscription · GET /organizations/{id}/members (for the member picker)
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Plan card | card | OrganizationSubscription.plan + Subscription.status, current_period_end, cancel_at_period_end | |
| Billed users | key-value | 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 | member_scope all_members | allowlist | denylist | COPY labels |
| Members | member-picker | member_overrides (active members only) | visible for allowlist/denylist |
| Subscribe the organization | primary-button | → BIL-03 org checkout (member_scope optional) | org_admin; when no subscription or not in force |
| Manage billing | button | → 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.
| 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.
Loads: GET /tenants · GET /tenants/{id} · GET /tenants/{id}/tier-change-requests
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Facts | key-value | Tenant: tier, region, status, owner, application_count | status migrating → banner, writes may 503 |
| Tier change requests | list | TenantTierChangeRequest: from→requested tier/region, status, scheduled_for, reason, notes | |
| Request change | button | → 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.
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 | Data / API | Rules |
|---|---|---|---|
| Filters | search + menus | time range (presets 1h/24h/7d/custom), actor type, action (grouped by prefix, wildcard), resource | filters encoded in URL/state for sharing |
| Rows | table | timestamp, actor (type icon + name/id), action, target (type + link), app/org | relative time with absolute on hover/long-press |
| Inspector | side panel | → 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.
Loads: GET /audit-events/{id}
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Facts | key-value | action, actor, target, target_user_id, application_id, organization_id, tenant_id, request_id, timestamp | each id links to its screen |
| Diff | diff view | 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.
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 | Data / API | Rules |
|---|---|---|---|
| Filter | segmented | Unread · All · Archived (state) | default All (unarchived) |
| App filter | menu | application_id, source=platform ("Substratal") | per-app unread counts from unread-count.applications[] |
| Rows | 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 | 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 | bulk Mark read / Mark unread / Archive (one PATCH per row) | |
| Row actions | row-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.
Loads: GET /users/me/notifications/{notificationId}
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Header | icon + app name + time | application_id / source, created_at | |
| Title & body | text | title, body | full text |
| Open | primary-button | 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 | 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 | 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 | 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.
Loads: GET /users/me/devices?include_inactive=true · GET /users/me/settings (notifications.push)
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| This device | card | 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 | Settings.notifications.push | same control as SET-01 |
| Devices | 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 | PATCH /users/me/devices/{deviceId} {name} | |
| Send test notification | row-button | POST /users/me/devices/{deviceId}/test | Hub devices only; shows provider result inline (sent / failed + error) within 10 s |
| Remove | row-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.
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 | Data / API | Rules |
|---|---|---|---|
| Primer | text + illustration | what you'll get: security alerts, invitations, access changes; "You can change this anytime" | |
| Turn on | primary-button | OS permission prompt | platform API per PLATFORM-SPEC |
| Not now | button | 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.
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 | Data / API | Rules |
|---|---|---|---|
| Inbox | table | 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 | deliveries[]: provider, status, attempts, error, sent_at | |
| Devices | table | Device: app, provider, environment, status, inactive_reason, name, app_version, last_registered_at, last_delivery_at | |
| Delete device | destructive-button | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Recipient | picker | user (GET /users?q) | must have active access to the chosen app |
| Application | select | body.application_id | required for platform senders |
| Category | text-field | body.category | ^[a-z][a-z0-9_](.[a-z0-9_]+)$; security./account./billing. reserved (422 reserved_category) |
| Priority | segmented | body.priority normal | high | high explains provider throttling |
| Title | text-field | body.title | 1–120 |
| Body | text-area | body.body | ≤ 1,000 |
| Data | key/value editor | body.data | ≤ 20 string pairs, 2 KB; key substratal reserved |
| Open link | url-field | body.action_url | https or private scheme ≤ 2,048 |
| Advanced | disclosure | push (bool), badge, sound default|silent, collapse_key, ttl_seconds | |
| Payload size | meters | client estimate of the 4 KB push payload | 422 notification_too_large → details.bytes/max |
| Preview | card | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Query | search | Cmd/Ctrl+K (desktop), search button (touch) | |
| Results | grouped list | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Administration | 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 | 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.
| Element | Kind | Data / API | Rules |
|---|---|---|---|
| Offline | banner | network status | reads from cache, writes disabled with explanation |
| Maintenance / tenant migrating | banner | 503 service_unavailable + Retry-After | countdown, auto-retry reads |
| Session ended | modal → AUTH-01 | 401 after failed refresh | preserve destination |
| Forbidden | inline state | 403 | COPY |
| Not built yet | inline state | 501 not_implemented (Notifications today) | hide the feature behind its flag; never show an error to end users |
| Update required | blocking screen | 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.