# Zync OS — Plan C: Advanced OS Features + Portal/Staff/Admin Shells (design)

Slug: `zync-os-advanced` · Date: 2026-07-11 · Audience: AI coding agents (run-plan codex seats, reviewers, vision judges). Source vision: `Zync OS Design.pdf` (pp. 9–11, 26–31, 37–39, 44–45). Predecessors: Plan A (**LANDED master 2026-07-10, merge `a37d90d1`**) and Plan B (`docs/specs/2026-07-11-zync-os-mobile-design.md` — must land before this runs; `base_branch` = where B landed). Extends their contracts additively, never forks.

## Goal

Four strands:

1. **Desktop power features**: virtual desktops, saved workspaces (personal + admin-shared), focus mode, lock screen, app folders (desktop + mobile home), taskbar position/auto-hide, extended snap (thirds + n-window group resize), rebindable shortcuts + OS shortcut overlay.
2. **System services**: Background Activity Center (cross-module job aggregation), integration/app-health surface, tenant switcher as an OS-level profile switch (existing `POST /api/auth/switch-tenant` + `tenant_memberships` — verified present).
3. **Audience shells**: white-label shell theming on the EXISTING `white_label_configs` authority, customer-portal simplified shell, staff shell via role-based desktop templates.
4. **Zync Control Center**: `apps/zync-admin` adopts the OS shell with a distinct technical identity — enabled by extracting the shell core to `packages/os-shell`.

Premium feel remains owned by `.claude/skills/zc-ui-ux-designer` — this plan ADDS the missing physics sections to the skill (Wave-1 amendment task, §14). Specs/plans point, never restate.

## Non-goals

- AI context-action system — separate spec.
- Tablet side-by-side / mobile multi-instance.
- Live shell handoff on resize (Plan B decision stands).
- Contractor portal rework — stays magic-link + own minimal chrome; ONLY the customer portal gets the shell treatment.
- New notification/email/push infrastructure — activity center and health AGGREGATE existing seams only.
- Module marketplace/commerce — Module Manager surfaces existing enable/disable + plan gating only.
- New auth primitives — tenant switch, re-auth, idle policy all reuse existing routes/middleware (§4, §10).

## Current-state anchors (verified against LANDED master, 2026-07-11)

- Shell (post A; B adds `os/mobile/**`, `guards.ts`, discriminated `OsHistoryState`, purge-user-caches SW message): `apps/zync-app/src/os/**` — see Plan B anchors for the file list and the landed `ZyncModuleOs` interface in `packages/modules/src/manifest.ts` (NO `os-types.ts`).
- Persistence: `shell_layouts` (migration 0069, PK tenant/user/device_class, `payload jsonb`, `version`); client zod in `apps/zync-app/src/os/shell-layout-schema.ts`; API `GET/PUT /api/shell/layout?device=<class>`.
- **Tenant switching EXISTS**: `POST /api/auth/switch-tenant` (`apps/zync-api/src/routes/auth/switch-tenant.ts`, foundation-auth-rbac T12) over `tenant_memberships` (`packages/db/src/schema/rbac.ts`).
- **Re-auth EXISTS**: `POST /api/reauth` (`apps/zync-api/src/routes/reauth.ts`) — password-only, timing-safe, **400** for OAuth-only accounts (no passwordHash — distinct from 401 wrong-password), requires `session.type === 'user'` (admin sessions NOT supported). **NO rate limiter today** — §5 adds one.
- **Tenant switch 2FA branches EXIST**: `switch-tenant.ts` may return `requires_2fa` / `requires_2fa_setup` before issuing the new session — §10 handles both.
- **Idle policy EXISTS**: `apps/zync-app/src/features/security/IdleTimeoutProvider.tsx` + `SecurityPolicy.tsx`, `apps/zync-api/src/routes/security-settings.ts`, `middleware/session-guard.ts`, `cron/session-idle-cleanup.ts`.
- **White-label EXISTS**: `white_label_configs` (`packages/db/src/schema/white-label.ts`) — `custom_domain` (unique), `ssl_status`, `brand_name`, `logo_url`, `primary_color`, `favicon_url`, `custom_css`, `api_domain`, `portal_domain`; routes `apps/zync-api/src/routes/settings/white-label.ts`; `WhiteLabelUpsell` plan gating.
- **Customer portal**: path-based `/portal/:tenantSlug/*` (`apps/zync-app/src/portal/`, own `portal_sessions` auth + revocation, `usePortalBootstrap(tenantSlug)` carries `portalName`/branding). Portal apps that EXIST: index dashboard, `projects`, `invoices`, `proposals`, `tickets`(+`:id`), `kb`(+space/article), `profile` (+auth pages). There are NO portal files/receipts/payments routes — do not invent them.
- **Admin**: `apps/zync-admin` separate SPA, guard role `SUPER_ADMIN` (client + server-enforced 403), pages Overview/Tenants/TenantDetail/AdminRoles/TaxRates/BillingPlans/Reports/Analytics/Incidents; shares `@zync/ui`.
- Existing job endpoints (activity-center sources) enumerated by Wave-1 audit: accountant-export export jobs, imports, report/xlsx generation, marketing sends. Integration-status sources: invoicing adapter (Morning), SMTP, calendar, push subscription.
- RBAC: `roles` table (tenant-scoped) — templates reference `role_id`, never role-name text.

## Architecture

### 0. Shell core extraction (`packages/os-shell`) — Wave 1 enabler

Move the shell ENGINE from `apps/zync-app/src/os/**` into workspace package `packages/os-shell`; zync-app keeps bindings, manifest→descriptor mapping, chrome glue, auth/session integration.

**Host contract (the seam that makes portal/admin possible — pinned, generic over app ids):**

```ts
// packages/os-shell — knows NOTHING about ModuleId, tenants, or zync-api.
export type ShellAppDescriptor = {
  appId: string                          // host-scoped id; zync-app maps ModuleId → appId 1:1 (identity — payloads store the same string)
  label: string
  icon: string                           // lucide name
  navGroup?: string                      // start-menu/app-drawer grouping (landed manifest category)
  routing: { routePrefixes: string[]; defaultRoute: string }
  desktop?: { minWidth: number; minHeight: number; supportsMultipleInstances: boolean }
  mobile?: { presentation: 'fullscreen'; showInAppDrawer?: boolean; dockDefault?: boolean; homeDefault?: boolean }
  badgeId?: string
  widgets?: readonly { id: string; title: string }[]
  quickActions?: readonly { id: string; label: string; href: string }[]  // icon context menu + command center (landed manifest quick actions)
  notifications?: { priorityKinds: string[] }                            // §4 focus-mode passthrough
}
export type SerializedLocation = { pathname: string; search: string; hash: string }
// WindowRecord / IconRecord / WidgetRecord: the LANDED record shapes from the shared schema
// (Plan B moved it to packages/modules/src/shell-layout-schema.ts, imported by server + client),
// re-exported from packages/os-shell. WIRE KEY LAW: persisted records keep the landed field name
// `moduleId` — it is the legacy wire key whose VALUE is an appId (identity in zync-app). os-shell
// reads/writes the `moduleId` field; NO migrator renames it (renaming would break the landed
// server zod + stored rows for zero gain).
export type ShellCapabilities = {
  virtualDesktops: boolean; workspaces: boolean; focusMode: boolean; lockScreen: boolean
  activityCenter: boolean; tenantSwitcher: boolean; folders: boolean
}
export type HostAppBindings = {
  titleFromRoute?: (loc: SerializedLocation) => string | null
  badgeQuery?: () => QueryOptions                 // TanStack query-option factory (NOT a hook)
  widgetQueries?: Record<string, () => QueryOptions>
  activitySources?: () => QueryOptions[]          // §6
  healthSources?: () => QueryOptions[]            // §7
}
export type ShellHost = {
  hostId: 'app' | 'portal' | 'admin'
  basePath: string                       // RESOLVED value, never a pattern: '' (app/admin) | `/portal/${tenantSlug}` —
                                         // portal host computes it from the resolved `:tenantSlug` ROUTE PARAM
                                         // (usePortalBootstrap consumes that param, it does not return a slug);
                                         // all shell routing/url-sync is basePath-relative
  apps: ShellAppDescriptor[]             // already permission-filtered by the host
  renderApp(appId: string, instanceId: string): React.ReactNode
  // ^ returns the app's ROUTE TREE element; os-shell owns WindowRouter/MobileAppFrame (per-instance
  //   memory-router + location plumbing move INTO the package) and mounts renderApp output inside it.
  bindings: Record<string, HostAppBindings>
  search?: { placeholder: string; commandMode: boolean }   // portal: commandMode=false (§12)
  principalKey: string                   // opaque stable id for storage partitioning: app = `${tenantId}.${userId}`;
                                         // admin = admin user id; portal = portal-session principal (see below)
  persistence?: ShellPersistenceClient   // OPTIONAL; absent → os-shell's built-in localStorage-only client keyed
                                         // `zync.shell.<hostId>.<principalKey>.<deviceClass>` (portal/admin — their
                                         // sessions cannot use the tenant-user layout API). zync-app injects a
                                         // COMPOSITE client: shell-layout API + the principal-keyed local mirror
                                         // with Plan B's exact key + legacy-key migration contract — mirror behavior
                                         // moves INTO that client, unchanged (zero-behavior-change extraction holds).
  features: ShellFeatureSet              // static composition seam — see tree-shaking law below
  lock?: { policyMinutes: () => number | null
           reauth: (secret: string) => Promise<{ ok: true } | { ok: false; error: 'invalid' | 'oauth_only' | 'rate_limited' | 'unavailable' | 'network'; retryAfterSeconds?: number }>
           supportsInlineUnlock: boolean  // false for OAuth-only accounts → "Sign in again" path (discovery in §5)
           onSignOut: () => void }
  telemetry: (counter: string) => void
  capabilities: ShellCapabilities
}
export type ShellPersistenceClient = {
  load(deviceClass: string): Promise<{ payload: unknown; version: number } | null>
  save(deviceClass: string, payload: unknown, version: number): Promise<{ version: number } | 'conflict'>
  reset(deviceClass: string): Promise<void>
}
export { OsShell, MobileShell, PortalShell, createShellRuntime }
```

- zync-app adapter maps `MODULE_MANIFEST` + landed bindings onto descriptors (mechanical). Registry/router/stores operate on `appId` strings only. **appId ≡ ModuleId in zync-app** (1:1, same string) — persisted payloads need no translation; the extraction task asserts identity with a unit test.
- **Parity acceptance:** the extraction ships ZERO behavior change — the FULL landed A+B suites (unit matrices, behavioral probes, baselines, e2e) run against the extracted package and must be green UNCHANGED; that suite IS the parity test, no new parity harness.
- **Tree-shaking is entrypoint-based, not flag-based — and the seam is `ShellFeatureSet`**: `packages/os-shell` exports one lazy-loadable module per capability feature (`os-shell/features/virtual-desktops`, `/workspaces`, `/focus`, `/lock`, `/activity`, `/tenant-switcher`, `/folders`). Each host authors its own `shell-features.ts` that statically imports ONLY its enabled features and passes them as `ShellHost.features: ShellFeatureSet = Partial<Record<CapabilityKey, () => Promise<ShellFeatureModule>>>` (lazy factories; `ShellFeatureModule` = the feature's registration object: components + command/registry hooks). The generic shell renders a feature IFF `capabilities[key] && features[key]` — capability booleans gate runtime, the host's static import list gates the bundle. Entrypoints pinned: zync-app imports all seven; portal imports none; admin imports none. Bundle assertion per host (portal/admin bundles contain no virtual-desktop/workspace code).
- Extraction is mechanical (git mv + import rewrite + package.json, `file:` dep depth-independent per commit `7cd61f52` law); NO behavior change; the FULL A+B verification suite green unchanged IS the acceptance. Falsifier: extraction breaks HMR/bundle badly → fallback = zync-admin imports a build-exported subpath from zync-app workspace pkg; recorded at Wave-1 gate.

### 1. Canonical layout schema v2 (single owner, blocks all C features)

One Wave-1 task extends `shell-layout-schema.ts` — the ONLY file that defines layout payloads, for BOTH device classes:

```ts
// THE ENVELOPE OWNS THE VERSION — `v` lives ONLY on the envelope, never inside data:
type EnvelopeV2 = { v: 2, writer, committedAt, data: DesktopDataV2 | MobileDataV2 }
// DesktopDataV2 — SUPERSET of landed v1 data: every v1 field carries over, incl. onboarding
{ desktops: [{ id, name, windows: WindowRecord[] }], activeDesktopId,
  taskbar: { pinned: string[], position: 'bottom'|'start'|'end', autoHide: boolean },
  icons: IconRecord[], widgets: WidgetRecord[], folders: FolderRecord[],
  shortcuts?: Record<ActionId, string>,
  onboarding?: { coachMarksCompleted?: boolean } }   // landed v1 field — NEVER dropped by migration
// MobileDataV2 (Plan B mobile data + folders; B's coach-mark flag carries over likewise)
{ homeIcons, dock, widgets, folders: FolderRecord[], onboarding? }
// FolderRecord = { folderId: string, name: string, memberAppIds: string[], cell: number }
// WindowRecord/IconRecord/WidgetRecord = landed shapes with the landed `moduleId` wire key (§0 wire-key law);
// v2 migrators NEVER rename fields — values are appIds (identity in zync-app)
```

- Strict zod for BOTH v2 payloads (`.strict()` objects, same rigor as landed v1 schema) — the SHARED schema source (Plan B moved it to `packages/modules/src/shell-layout-schema.ts`, imported by the server route AND the client) stays the single owner; **v2 is added THERE, so the server validates v2 too** (the server is NOT opaque — it zod-validates the envelope `{ v, writer, committedAt, data }`; the route's accepted `v` set widens to `1 | 2`).
- ONE pure migrator `migrateLayoutEnvelope(raw: unknown) → EnvelopeV2 | null` — takes and returns the COMPLETE envelope (writer/committedAt preserved), explicit per-source rules: **unversioned/garbage → null → defaults**; **v1 desktop (landed shape) → v2**: windows → single desktop "Desktop 1", taskbar pinned preserved + position `'bottom'`/autoHide `false`, icons/widgets/`onboarding` copied verbatim, folders `[]`; **v1 mobile (Plan B shape) → v2**: homeIcons/dock/widgets/onboarding copied, folders `[]`. Unit matrix covers every rule incl. onboarding round-trip. Migration runs client-side on read (server keeps stored v1 rows valid; next PUT writes v2).
- Later C features (desktops §2, folders §8, taskbar §8, workspaces §3) consume this schema; NONE may redefine it. Wave ordering: schema task → everything else.
- Caps: 12 windows per desktop (the landed cap becomes per-desktop); GLOBAL mounted-window cap 12 with LRU-unmount across background desktops (guards via Plan B `canDiscard`; unmounted windows keep their record, remount on desktop switch). **Cap exhaustion (every eviction candidate blocked): reject the open/remount — state retained, toast names blockers, `app_cap_hit` counter (Plan B's rule, same test in this store's matrix).**

### 2. Virtual desktops (desktop class only)

- Store: `desktops` + `activeDesktopId` per §1; existing window actions scope to the active desktop (mechanical selector indirection; landed unit matrix re-run).
- Max 6. Create/rename/delete (delete MOVES its windows to the neighbor desktop — never discards, no guard needed; **capacity-aware**: if `neighbor.windows + moved > 12` the delete is blocked with a toast naming the overflow count — user closes windows first, nothing auto-closed), switch via taskbar task-view button, `Ctrl+Alt+←/→`, overview. Move-window via title-bar context menu + drag-onto-thumbnail in overview (same capacity check per target desktop).
- **Overview surface** (`DesktopOverview`): zoom-out grid; ACTIVE desktop = live scaled subtree; inactive = static composite (icon+title cards) — never offscreen-render all desktops. Physics per new desktop.md §overview.
- URL: focused window of active desktop (existing rule). Desktop switch = `replaceState` (viewport movement, not navigation). popstate to a window on another desktop → switch + focus (url-sync owner-resolution gains desktop lookup).

### 3. Saved workspaces

- Snapshot: `{ name, desktops: [{ name, windows: [{ appId, location, rect }] }] }` — content state NOT captured; modules rehydrate.
- Personal: save-current / apply / delete via task-view menu + command center. Apply = close-all through `canDiscard` (blocked apps listed in toast; nothing closed until confirmed) then open snapshot; apps the user lacks permission for are SKIPPED with toast (client permission filter on apply — shared payloads never grant access).
- Admin-shared: admin marks a workspace `shared`; members see read-only copies in the picker; apply copies into session state (no live link).
- **Backend (exact contract):** table `shell_workspaces`:
  `id uuid PK default gen_random_uuid() · tenant_id uuid NOT NULL fk tenants cascade · owner_user_id uuid NOT NULL fk users cascade · name text NOT NULL · shared boolean NOT NULL default false · device_class text NOT NULL default 'desktop' · payload jsonb NOT NULL · version integer NOT NULL default 1 · created_at/updated_at timestamptz NOT NULL default now() · UNIQUE (tenant_id, owner_user_id, name)`.
  Routes (tenant-guard dual-layer per `tenant-guards.ts` law, zod, payload ≤64KB):
  `GET /api/shell/workspaces` → own + shared-in-tenant · `POST` → create own (cap 20/user → 409; creating with `shared:true` is admin-only) · `PATCH /:id` (zod body: `name?`, `payload?`, `shared?` — `shared` admin-only; optimistic concurrency: body carries `version`, mismatch → 409) · `DELETE /:id`.
  **Authz matrix (server-enforced, FIELD-level):** owner: CRUD on own rows EXCEPT the `shared` flag · `shared` flag (set/unset, on POST or PATCH): tenant admin ONLY (`requirePermission('settings:write')` — the exact existing key) — non-admin sending `shared` → 403 · admin: may also delete any shared row · member: READ shared rows only · cross-tenant caller: 404 (tenant predicate, both layers) · portal/admin sessions: 401. **Caps race-safe:** BOTH caps (20/user personal, 20/tenant shared) enforced inside the write transaction under `pg_advisory_xact_lock(hashtext('shell_ws:' || tenant_id))` (serializes concurrent cap-relevant writes per tenant — a bare count+write CTE still races under concurrent transactions) → 409 over cap. **EVERY mutation** (create/update/delete, shared or not) → `auditLog` forward (existing seam).

### 4. Focus mode

- Store: `focus: { active, allowedAppIds, endsAt, snapshot }` — the FULL record (active flag + allowlist + timer end + pre-focus desktops snapshot) persists in ONE sessionStorage entry **keyed `zync.focus.<tenantId>.<userId>`** so reload mid-focus restores focus mode intact (acceptance 4 depends on this); never server-persisted. Cleared on logout AND in the tenant-switch purge step (§10) — a same-tab principal change must never restore another principal's snapshot.
- Enter (quick-settings tile, `>focus`, `Ctrl+Alt+F`): pick allowed apps (default: focused app + timer); other windows minimize, icons/widgets hide, taskbar collapses to allowed + clock + focus chip; toasts suppressed EXCEPT priority kinds (new optional pure-data manifest field `notifications?: { priorityKinds: string[] }`, default none) — suppressed accumulate in shade/tray unread (presentation-only filter, no data loss). Timer chip prominent (PDF p37 pairing).
- Exit restores snapshot via normal open path; disabled/removed modules skipped with toast. Announcer on enter/exit + suppressed-count.

### 5. Lock screen

- Client overlay (`LockScreen`), token `--z-lock` above `--z-system-layer` (token task first). Triggers: start-menu Lock, `Ctrl+Alt+L`, idle per tenant policy.
- **Reuses existing seams — no new auth:** idle detection integrates with `IdleTimeoutProvider` (one idle authority); tenant policy field `auto_lock_minutes` (nullable=off) added to the EXISTING security-settings route/schema (`security-settings.ts`) alongside the current idle-timeout field — **cross-field rule: when BOTH are set, `auto_lock_minutes < idle-logout minutes` (equal/greater rejected — lock MUST fire before logout or it never shows); `null` (lock off) is always valid; validated against the EFFECTIVE pair (partial update merges submitted fields with stored values server-side before the zod refine) + settings-UI inline error**; the audit task records exact field names and the settings UI section. Unlock = `POST /api/reauth`; adapter maps responses to the typed result (§0 `ShellHost.lock.reauth`): 200 → `ok`, 401 → `invalid` (wrong password), **400 → `oauth_only`**, **429 → `rate_limited` with `retryAfterSeconds` from the Retry-After header**, 5xx/network → `unavailable`/`network` (lock stays, retry). **OAuth-only discovery (`supportsInlineUnlock`):** the session/me payload exposes no password capability today — the same task adds `has_password: boolean` to the EXISTING me/session response (derived from `users.password_hash IS NOT NULL`; no new endpoint); lock adapter reads it at init.
- **Rate limiting (NEW task, exact contract):** `/api/reauth` has NO limiter today. Reuse the existing `RATE_LIMITER_AUTH` Workers rate-limit binding (the login seam) with key `reauth:<userId>` (user-keyed — lock brute-force is per-account, unlike login's IP keying). The CF binding returns only allow/deny (no reset metadata): on deny → `429` with header `Retry-After: 60` (the binding's fixed window length — a constant, pinned next to the limiter call); adapter maps it to `rate_limited` + `retryAfterSeconds` from that header. Binding unavailable → fail-closed 429 (same policy as login). Route test: N+1th attempt inside the window → 429 + header.
- Locked: shell content `display:none` under the overlay (NOT blur — privacy law; screen readers cannot reach it), session storage intact, background activity (SW/push) unaffected; notifications show COUNT only. Session expiry while locked → normal 401 → login.
- Mobile: same minus keyboard trigger. Admin host: `lockScreen: false` (reauth rejects admin sessions — §13).

### 6. Background Activity Center

- Client aggregation only — NO new backend. Binding seam (hook-safe, pinned): per-app bindings expose `activitySources?: () => QueryOptionsList` — **query-option FACTORIES, not hooks**; the single `ActivityCenter` component calls `useQueries({ queries: hosts.flatMap(...) })` (stable hook count; registry changes flow through array identity).
- `ActivityJob = { id, appId, label, status: 'running'|'done'|'failed', progress?, startedAt, completedAt?, errorMessage?, href? }` (href = deep link into the owning module's job UI; the center owns ZERO retry/create logic).
- Sources = Wave-1 audit's endpoint list (export jobs, imports, report/xlsx, marketing sends). Surfaces: tray "Activity" section + running-count badge, mobile shade section, quick-settings tile. Done/failed persist 24h client-side — localStorage ring **keyed `zync.activity.<hostId>.<tenantId>.<userId>`** (cap 50); ring cleared in the logout purge AND the tenant-switch purge step (§10) — never leaks entries across principals or tenants. Polling: ≤1/30s closed, 5s open (refetchInterval switch).

### 7. Integration & app health

- Same factory pattern: `healthSources?: () => QueryOptionsList` → `ModuleHealth = { status: 'ok'|'warn'|'error', message?, settingsHref? }`. Occupants: audit-confirmed only (Morning invoicing, SMTP, calendar, push subscription).
- Surfaces: tray health rollup (worst status), Module Manager per-app line, icon badge dot on `error` (taskbar + mobile home). Never blocks opening an app.

### 8. Desktop chrome extensions + Module Manager

- **Taskbar**: `position: 'bottom'|'start'|'end'` (logical under RTL) + `autoHide` — §1 payload; vertical variants + reveal physics per skill amendments.
- **Snap extended**: thirds on ≥1440px + snap-group shared-edge resize (n-window generalization); snap-layout picker on maximize-hover. Per skill §snap amendments.
- **Always-on-top**: context-menu toggle, one per desktop, z band `--z-window-pinned` (token task).
- **Rebindable shortcuts**: `SHORTCUT_DEFAULTS` data table + user overrides (`shortcuts` in §1 payload); conflict detection on rebind (reject + toast); OS shortcut overlay (`Ctrl+/`, grouped, searchable) absorbing classic ShortcutHelpOverlay in OS mode.
- **App folders**: §1 `FolderRecord` on desktop icons + mobile home. Open = anchored popover grid (desktop) / centered expand overlay (mobile); drag-icon-onto-icon creates; dissolves when empty; keyboard: Enter/Escape group semantics. Physics per skill amendments (both reference files).
- **Module Manager** (new system osApp `module_manager`, admin-permission-gated): grid of TOGGLEABLE + plan-gated manifest modules — `system`/always-on modules (today, notifications, settings, module_manager itself) EXCLUDED from the grid (nothing actionable; listing them invites disable attempts the API rejects) — icon/description/status (enabled/disabled/plan-locked from existing tenant-module API + `TOGGLEABLE_MODULE_IDS`), enable/disable via EXISTING endpoints, dependency warnings from manifest `dependencies` (hard block / soft warn), plan-locked → billing deep link. Disable removes the module from ALL shell surfaces live (selectors already reactive); running windows close through `canDiscard` (blocked → listed, user confirms or cancels the disable).

### 9. White-label shell (authority = existing `white_label_configs`)

- EXTEND the existing table + settings route (no parallel config): new columns `wallpaper_url text`, `hide_powered_by boolean NOT NULL default false`. Existing columns REUSED: `brand_name` (product name), `logo_url`, `primary_color` (accent seed), `favicon_url`, `custom_domain`. **Route contract (today's route is domain-only POST — this EXTENDS it):** `GET /api/settings/white-label` → full config row (exists or defaults) · **NEW** `PATCH /api/settings/white-label` — zod-picked branding fields ONLY (`brand_name`, `logo_url`, `primary_color`, `favicon_url`, `custom_css`, `hide_powered_by` — NOT `wallpaper_url`, which only the upload/DELETE routes below mutate); domain lifecycle stays in the existing POST/verify flow, PATCH never touches `custom_domain`/`ssl_status`. Plan gating enforced SERVER-side on PATCH (same plan-flag seam as today's `WhiteLabelUpsell`); admin-permission-gated.
- Application: accent recomputed from `primary_color` via one OKLCH utility, contrast-guarded (any computed pair failing 4.5:1 → default accent + admin health warn); wallpaper on desktop/lock/mobile home (upload + serving contracts below); `brand_name` in boot splash/start header/title suffix.
- **Dynamic PWA manifest — custom-domain hosts ONLY**: worker route `GET /manifest.webmanifest` resolves tenant by normalized Host header against `custom_domain` **WHERE `ssl_status = 'active'`** (pending/failed domains → default manifest; never serve tenant branding on an unverified domain); unknown host → default Zync manifest. `start_url`/`scope` = `/`; `Cache-Control: max-age=3600`. Path-based `/portal/:tenantSlug` on the shared origin gets NO per-tenant manifest (one manifest per origin scope — browser limitation; runtime theming only; documented in spec + portal settings UI).
- **Portal delivery contract:** the live portal bootstrap exposes only legacy portal name/logo/color fields, and the settings endpoint needs a tenant-user session — so the EXISTING portal bootstrap route is EXTENDED with a `white_label` object: `{ brand_name, logo_url, primary_color, favicon_url, wallpaper_url, hide_powered_by }`, populated from `white_label_configs` server-side ONLY when the tenant's plan flag allows (else `null` → portal renders defaults + "Powered by Zync"). Listed in the backend surface.
- **Wallpaper transport (existing routes CANNOT carry it — the presign flow is logo-specific and the public proxy allowlists `logo.png|jpg` only):** DIRECT upload, the proven avatar pattern (bytes in the request, validated BEFORE storage — no presign/confirm split). Upload: `POST /api/settings/white-label/wallpaper` — `requirePermission('settings:write')`, plan-gated, body = raw image bytes; server validates size ≤5MB + magic bytes (`image/png|jpeg|webp`), writes via STORAGE binding to a VERSIONED key `tenants/<tenantId>/wallpaper-<hex8>.<ext>` (`<hex8>` = first 8 hex of the content sha-256; key shape matches the proxy's `tenants/<tenantId>/<file>` construction). **Ordering (versioned keys make replacement atomic-enough without a two-phase flow):** write new object → persist `wallpaper_url` → on DB failure delete the new object (compensation) and 500 (live URL untouched) → on success best-effort delete the previous object (parsed from the old URL; cleanup failure logged, never surfaced). Same-content re-upload is a no-op (same key/URL). Response: `{ wallpaper_url }`. `DELETE /api/settings/white-label/wallpaper`: null the column FIRST (authoritative), then best-effort delete the captured object (logged on failure). **`wallpaper_url` is REMOVED from the generic PATCH zod — only this upload/DELETE pair mutates it** (PATCH mutating the URL would bypass validation). Serving: the public tenant-logo proxy widens its strict filename allowlist with the regex `wallpaper-[a-f0-9]{8}\.(png|jpg|webp)`, forced Content-Type from the extension — same guard structure and caching as logos (every content-changing upload mints a NEW URL, so the proxy's existing `max-age` caching is correct; no revalidation carve-out needed). Route test: replacement flips the URL, old key 404s after cleanup.

### 10. Tenant switcher (existing endpoint — definite feature)

- OS switcher (start-menu footer + tray) lists memberships (`tenant_memberships` via existing me/session seam; single-membership users see no switcher).
- **Switch state machine (point of no return = 200 with new session):**
  1. `canDiscard` sweep over all open windows (blocked → listed, confirm/cancel) → 2. save layout (PUT; failure → toast, abort) → 3. `POST /api/auth/switch-tenant`:
     - `requires_2fa` / `requires_2fa_setup` (verified branches) → switcher opens the existing login 2FA step component inline (code entry / setup redirect); cancel → abort, state intact.
     - error / network → abort, current tenant untouched, toast.
     - **200 (new session cookie issued) — point of no return**: old tenant session is gone; every later failure completes FORWARD into the new tenant, never back.
  4. SW purge `{type:'purge-user-caches'}` — **awaited via the Plan B MessageChannel ack** (timeout 2s → proceed + telemetry counter; purge is also idempotent on next boot) → 5. clear TanStack cache + activity ring (§6) + the old principal's focus record key (§4) → 6. shell re-boot with target tenant registry/branding/layout (mirrors login boot; layout fetch failure → registry defaults, toast).

### 11. Staff shell = role desktop templates (NOT a new shell)

- Registry already permission-filters (landed). Added: **desktop templates** — table `shell_templates`:
  `tenant_id uuid NOT NULL fk cascade · role_id uuid NOT NULL · device_class text NOT NULL CHECK (device_class IN ('desktop','mobile')) · payload jsonb NOT NULL · updated_at timestamptz NOT NULL default now() · updated_by uuid fk users · PK (tenant_id, role_id, device_class) · **composite FK `(tenant_id, role_id)` → `roles (tenant_id, id)`** (requires supporting unique index on roles — a plain `role_id → roles.id` FK would accept another tenant's role id; the composite FK makes cross-tenant role references unrepresentable, matching the tenant-guards dual-layer law at the schema level)`.
  Routes `GET/PUT /api/shell/templates/:roleId/:deviceClass` — admin-permission-gated writes, member READ of own role's template, tenant-guard dual-layer, zod (§1 schema), ≤64KB. Applied ONLY when the user has NO personal `shell_layouts` row (boot fallback: layout → template → registry defaults). Template edits never overwrite personal state.
- **Lifecycle = LAZY FALLBACK (one model, no seed, no copy-on-first-login):** on EVERY boot where the user has NO personal `shell_layouts` row, resolve `template row → built-in staff constant (non-admin roles) → registry defaults` and render from it WITHOUT writing a layout row; the personal row is created only by the user's own first commit-point save, and from then on always wins. Template edits therefore reach every member who hasn't personalized yet (including after a layout reset), and never overwrite personal state.
- Admin UI: "Desktop templates" section in existing roles settings — the editor IS the shell in template-edit mode (edit-as-role preview banner; saves to template route). No separate builder.
- PDF's staff desktop (My Work/Tasks/Time/Calendar/KB) ships as a **built-in default template CONSTANT, not a DB seed**: applied at boot-fallback time when a role has no `shell_templates` row AND the role LACKS the `settings:write` permission key (permission-derived "non-admin" test — no role-name matching). Lazy constant covers roles created later with zero seeding/backfill and stays idempotent by construction; admins fall through to registry defaults.

### 12. Customer portal shell

- Existing `portal_sessions` auth + `/portal/:tenantSlug/*` scheme UNTOUCHED. Presentation: **stacked full-screen apps on ALL device classes** (`PortalShell` = MobileShell interaction model with desktop-adapted visuals: centered max-width frame, dock as bottom app bar; no free window management — PDF simplification mandate). Capabilities: all false except folders:false trivially — no desktops/workspaces/focus/lock/activity/switcher; command center = search-only (no `>` commands).
- Portal descriptor list (EXISTING apps only): dashboard (index), projects, invoices, proposals, tickets, kb, profile. Bindings in `src/portal/os-bindings/`. Deep links from emails keep working (url-sync owner resolution under the RESOLVED `basePath`, §0). No server persistence (§0 — localStorage-only). **Portal `principalKey`:** the authed portal profile/session payload gains `principal_key` (opaque string derived from the portal session's contact subject id — the payload exposes no stable principal id today; tiny extension of the existing portal bootstrap/profile route, listed in the backend surface).
- White-labeled per §9; "Powered by Zync" footer per `hide_powered_by` plan flag. Portal shell is DEFAULT (no legacy checkbox); classic portal chrome deleted after acceptance 11 passes.

### 13. Zync Control Center (admin.zync.is)

- `apps/zync-admin` consumes `packages/os-shell`: admin descriptor list = existing pages (Overview, Tenants, Roles, Tax Rates, Billing Plans, Reports, Analytics, Incidents); capabilities `{ virtualDesktops:false, workspaces:false, focusMode:false, lockScreen:false, activityCenter:false, tenantSwitcher:false, folders:false }` — windows + taskbar + command center only. `lockScreen:false` because `/api/reauth` rejects admin sessions (verified).
- Distinct identity: Control-Center token overlay (denser spacing, technical accent, darker default) — tokens only, no component forks. Guard stays `SUPER_ADMIN` (existing client check + server 403). Zero tenant-app leakage (PDF p31 law).

### 14. Skill amendments (Wave 1, before any dependent UI wave — gate)

`references/desktop.md` gains: overview/desktop-switch, folder open/close, focus enter/exit, lock/unlock, activity tray rows, taskbar vertical + auto-hide reveal, snap-thirds picker, workspace-apply choreography. `references/mobile.md` gains: folder overlay, mobile lock. SKILL.md: new `data-fx` ids, amendment-scope rows, new tokens (`--z-lock`, `--z-window-pinned`, wallpaper dim recipe). Vision-judge doctrine updated in the same task.

## Backend surface (complete list)

1. `shell_workspaces` + CRUD (§3). 2. `shell_templates` + routes (§11). 3. Security-settings field `auto_lock_minutes` + cross-field validation (§5, existing route/schema extended). 4. Rate limiter on `/api/reauth` + `has_password` on the me/session payload (§5, existing seams). 5. Shared layout schema gains v2 (§1, `packages/modules` — server route accepts envelope `v: 1 | 2`). 6. `principal_key` on the portal profile/session payload (§12, existing route extended). 7. `white_label_configs` columns `wallpaper_url`, `hide_powered_by` + `GET`/`PATCH` branding contract (§9). 8. Dynamic manifest worker route, `ssl_status='active'` only (§9). 9. Portal bootstrap `white_label` object, plan-gated (§9/§12). 10. Wallpaper upload route + tenant-asset allowlist widening (§9). NOTHING else — switch-tenant, reauth, idle, module toggles, portal bootstrap all exist. All new tables: tenant-guard dual-layer, zod, standard migration flow, verify against prod schema (phantom-column class only visible live — wrangler tail on 500).

## Performance budget (additions)

- Desktop switch commit ≤200ms (transform-only slide; only the two desktops in transition painted). Overview open ≤300ms (live thumbnail = active only).
- Lock overlay paint <100ms (pre-mounted lazy chunk after first idle).
- Workspace apply: staggered opens per skill choreography; first window interactive ≤600ms.
- Capability-off hosts ship NO disabled-feature code (per-host entrypoint imports + bundle assertion, §0).

## Error handling (deltas)

| Failure | Surface |
|---|---|
| Workspace apply / module disable / tenant switch blocked by guards | toast lists blocking apps; nothing closed until confirmed (`canDiscard` contract) |
| Workspace/template payload invalid (zod) | discard + toast; personal layout untouched |
| v1→v2 migration failure | defaults + raw payload preserved server-side (never destructive) |
| Workspace PATCH version mismatch | 409 → refetch + retry prompt |
| Unlock endpoint down / rate-limited / OAuth-only account | lock stays; retry + cooldown message (429) + "Sign out" escape / "Sign in again" path (400 OAuth-only) |
| Tenant switch: 2FA required | inline 2FA step; cancel aborts, state intact |
| Tenant switch failure AFTER new session issued | complete forward into new tenant with defaults; never roll back |
| Branding asset 404 / contrast fail | default wallpaper/accent + admin health warn |
| Tenant switch failure BEFORE 200 (guards/save/2FA/switch error) | stay in current tenant, state intact, toast |
| Disabled module in focus allowlist / workspace / template | skipped with toast |

## Verification program

Landed 8-layer program + tiering, shared suite root. Additions: unit matrices (desktop-scoped store, §1 migrator, shortcut conflicts, contrast guard, workspace authz matrix as route tests incl. cross-tenant 404 + version 409); behavioral probes (desktop-switch, overview, folder, lock, auto-hide reveal); baselines (overview, folders desktop+mobile, lock, focus taskbar, vertical taskbar RTL, portal shell desktop+mobile, control center); vision judge on new keyframes with "distinct but same family" criterion for portal/admin; fault injection (workspace 409/413, template fetch fail, reauth 401/500, manifest host-miss, switch-tenant mid-flight). Per-host e2e projects: portal (portal-session fixture), admin (SUPER_ADMIN fixture). Full A+B regression suites green at the extraction gate (Wave 1) AND at close.

## Accessibility contract (deltas)

- Overview: grid pattern, arrow navigation, Enter focus / Delete close-window / F2 rename; announcer "Desktop 2 of 3".
- Folders: `role="group"`, popover focus-trap, Enter/Escape.
- Lock: overlay is the ONLY reachable tree content; focus on secret field; announcer lock/unlock.
- Focus mode: announcer enter/exit + suppressed count.
- Rebind UI: keyboard-recordable input, conflict announced; overlay searchable.
- Vertical taskbar: toolbar semantics, orientation announced, RTL mirror verified.

## Acceptance criteria (Plan C gate)

1. Extraction: zync-app on `packages/os-shell`, A+B suites green UNCHANGED; bundle parity ±5%.
2. Virtual desktops: create/rename/delete/switch/move via all paths; overview drag; URL rule (replaceState + cross-desktop popstate); v1 payloads migrate losslessly (unit matrix).
3. Workspaces: personal CRUD; shared visible read-only + applies with permission filtering; guard toast lists blockers; caps + version 409 enforced (shared cap transactional); authz matrix route-tests green incl. cross-tenant 404 + non-admin `shared` flag 403; every mutation audit-logged.
4. Focus mode: allowlist + priority kinds + accumulation + exact snapshot restore (incl. reload mid-focus) + timer pairing.
5. Lock: three triggers; content unreachable (DOM + axe assertion); count-only notifications; unlock via `/api/reauth` (typed results: 200/401/400/429/5xx each surfaced correctly); OAuth-only "Sign in again" path; reauth rate limiter live (429 route test); `auto_lock_minutes < idle-logout` validation enforced server-side.
6. Activity center: audited real jobs appear/progress/complete; failed deep-links to owner; polling discipline; hook-count stability test (toggle modules while open).
7. Health: audited integrations roll up; error badges; Module Manager status lines.
8. Module Manager: enable/disable live-updates every surface without reload; dependency + plan-lock rules; disable runs the guard contract.
9. Chrome: taskbar start/end/auto-hide (RTL); snap thirds + group resize; always-on-top; rebind + conflicts + overlay; folders desktop AND mobile with keyboard paths.
10. White-label: accent/wallpaper/brand-name applied; contrast fallback; custom-domain manifest per host, shared-origin portal correctly excluded; `hide_powered_by` plan-gated server-side.
11. Portal: stacked shell default on all devices; all 7 existing portal apps reachable; email deep links work; white-labeled; classic portal chrome removed; portal-session revocation e2e still green.
12. Staff templates: lazy-fallback lifecycle verified (no personal row → template/constant/defaults rendered, no row written; first personal save creates the row and wins thereafter; template edit reaches non-personalized members); edit-as-role editor saves to template route; built-in staff constant applies to non-admin roles; composite FK enforced.
13. Control center: zync-admin on os-shell, distinct identity, SUPER_ADMIN-only, minimal capabilities, zero tenant-app leakage.
14. Tenant switcher: memberships listed; full state machine verified (guards → save → switch incl. `requires_2fa`/`requires_2fa_setup` branches → awaited SW purge ack → cache+ring+focus-record clear → re-boot), pre-200 failure aborts cleanly, post-200 failure completes forward; single-membership users see no switcher.
15. Verification program green end-to-end (all layers, all hosts); skill amendments merged BEFORE dependent UI waves.

## Architecture decisions (settled — falsifier each)

- `packages/os-shell` with `ShellHost` adapter + per-host feature entrypoints, not per-host forks: three hosts would triple drift; descriptors decouple from ModuleId. Falsifier: host conditionals exceed ~15 branch sites → split presentation packages.
- Portal = stacked on all devices: external users are occasional; WM is a power-user surface. Falsifier: portal telemetry shows multi-app juggling → windowed portal opt-in later.
- Templates are a lazy boot-fallback, never a managed push or a copied row: overwriting personal layouts is user-hostile; PDF says default, not managed. Falsifier: kiosk demand → explicit `locked` flag as a new feature.
- Lock = client overlay + existing `/api/reauth`; server session lifecycle untouched (existing idle logout still applies and outranks lock). Falsifier: security review demands server-side lock state → session flag then.
- Activity center aggregates existing endpoints via query-option factories + one `useQueries`; owns zero job logic. Falsifier: >2 modules need identical job plumbing → shared job-store package extracted as its own task.
- Shared workspaces copy-on-apply (no live link); managed layouts are §11 templates. Falsifier: central-update demand → templates already cover it.
- White-label authority = existing `white_label_configs` extended, never a parallel shell-branding store. Falsifier: none — a second authority is the anti-pattern this decision exists to block.

## Out of scope pointers

AI depth spec consumes: app context payload (unchanged), command-center action registration. Future (unplanned): tablet side-by-side, kiosk/locked templates, marketplace commerce, contractor-portal shell, offline-first API caching (Plan B decision).
