Substratal Apps Substratal Apps Hub specs
GitHub

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 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 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 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
Email 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 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.