# Module Management & Dependencies — Implementation Plan

**Spec:** docs/specs/2026-05-31-module-management.md  ·  **Slug:** module-management  ·  **Wave:** 2
**Depends on:** foundation-auth-rbac

## Goal
Deliver per-tenant enable/disable control over all product modules, with a code-level dependency graph (hard vs. soft), cascading disable logic, data-preserving disable semantics, and a `/settings/modules` UI. This module is foundational: every other module reads its enabled state to gate routes, sidebar nav, cross-module fields, search, and notifications. It introduces the `tenant_modules` table, the `@zync/modules` package (manifest + dependency helpers), the module API endpoints, the RBAC permission keys `settings:modules:read`/`settings:modules:write`, and the frontend module store + `ModuleGuard`.

## Architecture
- **DB:** one new table `tenant_modules` (composite PK `(tenant_id, module_id)`) referencing the upstream `tenants(id)` and `users(id)` tables from `foundation-auth-rbac`. The synthetic `system` module is never stored — it is always-on by application convention.
- **`@zync/modules` package:** holds the `MODULE_MANIFEST` (dependency graph as release-time data) and pure dependency helpers (`getCascadeDisables`, `getSoftImpacts`, `canEnable`). No DB access; consumed by both API (server cascade authority) and app (UI preview, store).
- **API (`zync-api`, Hono):** `GET /api/settings/modules`, `PATCH /api/settings/modules/:moduleId`, `GET /api/settings/modules/:moduleId/impact`, and admin override `PATCH /api/admin/tenants/:tenantId/modules/:moduleId`. Permission checks reuse upstream `requirePermission(key)` and `requireAdminSession()` from `@zync/auth`. Tenant is read from JWT `tid` claim.
- **Seeding:** tenant creation (auth service, upstream) inserts 14 `tenant_modules` rows with `enabled=true`. This plan provides the exact seed SQL + a `seedTenantModules(db, tenantId)` query helper the auth service calls.
- **RBAC seed delta:** add two permission keys to the upstream `permissions` seed and grant them to `OWNER`/`ADMIN` system roles via `role_permissions`.
- **Frontend (`zync-app`, Vite+React):** Zustand `useModuleStore` loaded on app boot; `<ModuleGuard>` route wrapper; `/settings/modules` page with module cards, toggle, confirm modal, cascade/soft-impact preview from the `/impact` endpoint.

It consumes from `foundation-auth-rbac`: tables `tenants(id)`, `users(id)`, `roles(id, tenant_id, name, is_system_role)`, `permissions(id, key)`, `role_permissions(role_id, permission_id)`; exports `requirePermission`, `requireAdminSession`, the `SessionPayload` (`tid`, `type`) shape, and the `c.get('session')` Hono context value.

## Tech Stack
- **Package:** `packages/modules` (`@zync/modules`) — pure TypeScript, no runtime deps.
- **DB:** `packages/db` — Drizzle schema + query helpers; Neon Postgres via Cloudflare Hyperdrive.
- **API:** `apps/zync-api` — Hono on Cloudflare Workers; reuses `@zync/auth` middleware.
- **App:** `apps/zync-app` — Vite + React, Zustand store, React Router, design-system components.
- **Bindings:** Hyperdrive (Postgres connection); no new KV/R2 bindings required.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema + package | 1, 2, 3 | `packages/db/src/schema/tenant-modules.ts`, `packages/db/src/queries/tenant-modules.ts`, `packages/modules/*` | T2/T3 parallel after T1 types exist |
| B — RBAC seed delta | 4 | `packages/db` seed files | parallel with A |
| C — API | 5, 6, 7, 8 | `apps/zync-api/src/routes/settings-modules.ts`, `apps/zync-api/src/routes/admin-modules.ts`, middleware | T5 first; T6/T7/T8 parallel after T5 |
| C — API | 13 | `apps/zync-api/src/middleware/require-module-enabled.ts` | parallel with T6–T8 after T5 |
| D — frontend | 9, 10, 11, 12 | `apps/zync-app/src/stores/`, `apps/zync-app/src/components/`, `apps/zync-app/src/routes/settings/modules/` | T9 first; T10/T11/T12 parallel after T9 |

## Tasks

### Task 1: `tenant_modules` table + Drizzle schema
**Blocks:** 3, 5, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/tenant-modules.ts`
- Modify: `packages/db/src/schema/index.ts` (export new schema)
- Create: `packages/db/migrations/00XX_tenant_modules.sql` (raw Postgres migration)
**Steps:**
- [ ] Write the raw Postgres migration with composite PK, FKs to `tenants(id)` and `users(id)`, the `module_id` CHECK enum, and the tenant index.
- [ ] Mirror the table in Drizzle using `pgTable` with `primaryKey({ columns: [tenantId, moduleId] })`, a `check()` for `module_id`, and an index on `tenant_id`.
- [ ] Export `tenantModules` and inferred `TenantModuleRow` / `NewTenantModuleRow` types from the schema barrel.
**Schema / Interfaces:**
```sql
CREATE TABLE tenant_modules (
  tenant_id     UUID          NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  module_id     TEXT          NOT NULL,
  enabled       BOOLEAN       NOT NULL DEFAULT TRUE,
  enabled_at    TIMESTAMPTZ,
  disabled_at   TIMESTAMPTZ,
  disabled_by   UUID          REFERENCES users(id) ON DELETE SET NULL,
  override_by_system_admin BOOLEAN NOT NULL DEFAULT FALSE,
  created_at    TIMESTAMPTZ   NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ   NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, module_id),
  CONSTRAINT module_id_valid CHECK (module_id IN (
    'system', 'crm', 'customers', 'time_management', 'projects', 'tasks',
    'invoices', 'expenses', 'billing', 'calendar', 'marketing', 'reports',
    'kb', 'contractor_payouts', 'ai_assistant'
  ))
);

CREATE INDEX idx_tenant_modules_tenant_id ON tenant_modules (tenant_id);
```
```ts
// packages/db/src/schema/tenant-modules.ts (Drizzle)
import { pgTable, uuid, text, boolean, timestamp, index, primaryKey, check } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { tenants } from './tenants';
import { users } from './users';

export const tenantModules = pgTable('tenant_modules', {
  tenantId: uuid('tenant_id').notNull().references(() => tenants.id, { onDelete: 'cascade' }),
  moduleId: text('module_id').notNull(),
  enabled: boolean('enabled').notNull().default(true),
  enabledAt: timestamp('enabled_at', { withTimezone: true }),
  disabledAt: timestamp('disabled_at', { withTimezone: true }),
  disabledBy: uuid('disabled_by').references(() => users.id, { onDelete: 'set null' }),
  overrideBySystemAdmin: boolean('override_by_system_admin').notNull().default(false),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  pk: primaryKey({ columns: [t.tenantId, t.moduleId] }),
  tenantIdx: index('idx_tenant_modules_tenant_id').on(t.tenantId),
  moduleIdValid: check('module_id_valid', sql`${t.moduleId} IN (
    'system','crm','customers','time_management','projects','tasks',
    'invoices','expenses','billing','calendar','marketing','reports',
    'kb','contractor_payouts','ai_assistant')`),
}));

export type TenantModuleRow = typeof tenantModules.$inferSelect;
export type NewTenantModuleRow = typeof tenantModules.$inferInsert;
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon Postgres; `\d tenant_modules` shows composite PK, both FKs, the CHECK, and the index.
- [ ] `module_id` outside the enum is rejected by the DB.
- [ ] Drizzle types compile and are exported from the schema barrel.

### Task 2: `@zync/modules` package — manifest
**Blocks:** 3, 5, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/modules/package.json` (name `@zync/modules`, type module, exports `./manifest`, `./dependencies`)
- Create: `packages/modules/src/manifest.ts`
- Create: `packages/modules/src/index.ts` (re-export manifest + dependencies)
- Create: `packages/modules/tsconfig.json`
**Steps:**
- [ ] Declare the `ModuleId` union, `DependencyKind`, `ModuleDependency`, `ModuleDefinition` types verbatim.
- [ ] Populate `MODULE_MANIFEST` with all 15 modules (14 toggleable + `system`), correct `navGroup`, `alwaysOn`, `displayName`, `descriptionKey` (i18n key `modules.<id>.description`), `icon` (lucide component name), and the full dependency list from the spec's Dependency Detail.
- [ ] Export `TOGGLEABLE_MODULE_IDS` (all except `system`) and a `MODULE_CARD_ORDER` array matching the spec's card ordering.
- [ ] Export a `MODULE_BY_ID: Record<ModuleId, ModuleDefinition>` lookup.
**Schema / Interfaces:**
```ts
export type ModuleId =
  | 'system' | 'crm' | 'customers' | 'time_management' | 'projects'
  | 'tasks' | 'invoices' | 'expenses' | 'billing' | 'calendar'
  | 'marketing' | 'reports' | 'kb' | 'contractor_payouts' | 'ai_assistant';

export type DependencyKind = 'hard' | 'soft';

export interface ModuleDependency {
  moduleId: ModuleId;
  kind: DependencyKind;
  impactDescription: string;
}

export interface ModuleDefinition {
  id: ModuleId;
  displayName: string;
  descriptionKey: string;
  icon: string;
  navGroup: 'workspace' | 'business' | 'financials' | 'resources' | 'global' | null;
  dependencies: ModuleDependency[];
  alwaysOn: boolean;
}

// Dependencies transcribed from spec Dependency Detail:
// system            → []                 navGroup null,        alwaysOn true
// tasks             → []                 navGroup workspace
// customers         → []                 navGroup business
// calendar          → []                 navGroup workspace
// expenses          → []                 navGroup financials
// kb                → []                 navGroup resources
// projects          → soft customers ("Customer assignment field will be hidden on projects."),
//                      soft tasks ("Subtask panel will be hidden in project detail.")   navGroup workspace
// time_management   → hard tasks ("Time entries cannot be created without tasks.")       navGroup workspace
// invoices          → soft customers ("Recipient autocomplete hidden; free-text name entry still works.") navGroup financials
// billing           → hard invoices ("Recurring billing cannot generate invoices."),
//                      soft customers ("Subscription customer link hidden.")             navGroup financials
// crm               → soft customers ("Customer context panel hidden on ticket detail.") navGroup business
// contractor_payouts→ hard time_management ("Payouts cannot be computed without approved time entries.") navGroup financials
// marketing         → soft customers ("Lead-to-customer conversion unavailable."),
//                      soft calendar ("Booking/scheduling integration unavailable."),
//                      soft invoices ("Mini-ecommerce checkout unavailable.")            navGroup business
// reports           → soft invoices ("Revenue and tax reports unavailable."),
//                      soft expenses ("Expense reports unavailable."),
//                      soft time_management ("Utilization and billable-hours reports unavailable.") navGroup resources
// ai_assistant      → soft EACH of [tasks,customers,invoices,time_management,calendar,expenses,projects]
//                      ("AI features for <Module> context will be unavailable.")          navGroup global
export const MODULE_MANIFEST: ModuleDefinition[];
export const MODULE_BY_ID: Record<ModuleId, ModuleDefinition>;
export const TOGGLEABLE_MODULE_IDS: ModuleId[]; // all except 'system'
export const MODULE_CARD_ORDER: ModuleId[];
//  = ['tasks','projects','time_management','calendar','customers','crm','marketing',
//     'invoices','billing','expenses','contractor_payouts','reports','kb','ai_assistant']
```
**Acceptance:**
- [ ] `MODULE_MANIFEST` has exactly 15 entries; only `system` has `alwaysOn: true` and `navGroup: null`.
- [ ] Every dependency `moduleId` is a valid `ModuleId`; hard deps are exactly `time_management→tasks`, `contractor_payouts→time_management`, `billing→invoices`.
- [ ] `TOGGLEABLE_MODULE_IDS.length === 14` and excludes `system`.

### Task 3: `@zync/modules` package — dependency helpers
**Blocks:** 5, 6, 7, 11  ·  **Blocked by:** 2
**Files:**
- Create: `packages/modules/src/dependencies.ts`
**Steps:**
- [ ] Implement `getCascadeDisables`: depth-first traversal collecting every module that hard-depends (transitively) on `moduleId`, restricted to modules currently in `enabledModules`. Exclude `moduleId` itself. Order = DFS discovery order.
- [ ] Implement `getSoftImpacts`: collect, across the target plus all transitive cascade modules, every currently-enabled module that *soft*-depends on any module being disabled, returning `{ moduleId, impactDescription }`. De-duplicate by dependent `moduleId` (first impact wins). Exclude any module that is itself in the hard-cascade disable set — a module being auto-disabled must never also appear as a soft "loses functionality" entry.
- [ ] Implement `canEnable`: a module can be enabled iff all its *hard* dependency modules are in `enabledModules`. Return `missingHardDeps` for any not present.
- [ ] Guard: `canEnable('system', ...)` is irrelevant (always-on); `getCascadeDisables`/`getSoftImpacts` for `system` are never called (system cannot be toggled).
**Schema / Interfaces:**
```ts
import { type ModuleId, MODULE_MANIFEST, MODULE_BY_ID } from './manifest';

/** All modules auto-disabled (transitive hard deps) if moduleId is disabled. DFS order, excludes moduleId. */
export function getCascadeDisables(moduleId: ModuleId, enabledModules: ModuleId[]): ModuleId[];

/** All enabled modules that lose functionality (soft deps) across the disable set. Deduped by dependent. */
export function getSoftImpacts(
  moduleId: ModuleId,
  enabledModules: ModuleId[],
): Array<{ moduleId: ModuleId; impactDescription: string }>;

/** Whether moduleId can be enabled given enabledModules; lists missing hard deps. */
export function canEnable(
  moduleId: ModuleId,
  enabledModules: ModuleId[],
): { allowed: boolean; missingHardDeps: ModuleId[] };
```
**Acceptance:**
- [ ] `getCascadeDisables('tasks', ALL)` returns `['time_management','contractor_payouts']` (transitive).
- [ ] `getCascadeDisables('invoices', ALL)` returns `['billing']`.
- [ ] `getCascadeDisables('time_management', ALL)` returns `['contractor_payouts']`.
- [ ] `canEnable('billing', allEnabledExceptInvoices)` → `{ allowed:false, missingHardDeps:['invoices'] }`.
- [ ] `getSoftImpacts('customers', ALL)` includes `projects`, `invoices`, `billing`, `crm`, `marketing`, `ai_assistant` with their impact strings, no duplicates.

### Task 4: RBAC permission seed delta
**Blocks:** 5  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/seed/permissions.ts` (upstream permission seed list)
- Modify: `packages/db/src/seed/role-permissions.ts` (system-role grants)
**Steps:**
- [ ] Add permission keys `settings:modules:read` and `settings:modules:write` to the seeded `permissions` rows (with descriptions "View module states at /settings/modules" and "Toggle module enabled/disabled state").
- [ ] Grant both keys to the `OWNER` and `ADMIN` system roles in the `role_permissions` seed. Do **not** grant to `MEMBER`, `VIEWER`, `CONTRACTOR`. Grants are per-tenant (roles carry `tenant_id`): hook into the same per-tenant system-role seed path the auth dependency runs at tenant creation, not a one-time global seed, so every existing and future tenant's OWNER/ADMIN roles receive these grants.
- [ ] Ensure the seed is idempotent (`ON CONFLICT (key) DO NOTHING` for permissions; `ON CONFLICT (role_id, permission_id) DO NOTHING` for grants).
**Schema / Interfaces:**
```sql
INSERT INTO permissions (key, description) VALUES
  ('settings:modules:read',  'View module states at /settings/modules'),
  ('settings:modules:write', 'Toggle module enabled/disabled state')
ON CONFLICT (key) DO NOTHING;
-- grant to OWNER + ADMIN system roles per tenant via role_permissions
```
**Acceptance:**
- [ ] After seeding, `OWNER` and `ADMIN` roles include both keys; `MEMBER`/`VIEWER`/`CONTRACTOR` include neither.
- [ ] Re-running the seed produces no duplicate-key errors.

### Task 5: DB query helpers + tenant seeding
**Blocks:** 6, 7, 8  ·  **Blocked by:** 1, 2, 3, 4
**Files:**
- Create: `packages/db/src/queries/tenant-modules.ts`
- Modify: `packages/db/src/queries/index.ts` (export)
**Steps:**
- [ ] `seedTenantModules(db, tenantId)`: insert the 14 toggleable modules with `enabled=true, enabled_at=now()`, `ON CONFLICT DO NOTHING`. Never insert `system`. (Called by the auth tenant-creation service.)
- [ ] `getTenantModules(db, tenantId)`: return all `tenant_modules` rows for a tenant.
- [ ] `getEnabledModuleIds(db, tenantId)`: return `ModuleId[]` of enabled modules, always including `system` synthetically.
- [ ] `setModuleStates(db, tenantId, updates, actorUserId)`: transactional upsert of `{ moduleId, enabled }[]`; on disable set `disabled_at=now()`, `disabled_by=actorUserId`; on enable set `enabled_at=now()`, `disabled_by=null`; always bump `updated_at`. Reject `system`.
- [ ] `setModuleStateAdmin(db, tenantId, moduleId, enabled)`: unconditional single-row upsert setting `override_by_system_admin=true` (bypasses dependency checks). Reject `system`.
**Schema / Interfaces:**
```ts
import type { ModuleId } from '@zync/modules';

export function seedTenantModules(db: Db, tenantId: string): Promise<void>;
export function getTenantModules(db: Db, tenantId: string): Promise<TenantModuleRow[]>;
export function getEnabledModuleIds(db: Db, tenantId: string): Promise<ModuleId[]>; // includes 'system'
export function setModuleStates(
  db: Db,
  tenantId: string,
  updates: Array<{ moduleId: ModuleId; enabled: boolean }>,
  actorUserId: string,
): Promise<void>; // single transaction
export function setModuleStateAdmin(
  db: Db,
  tenantId: string,
  moduleId: ModuleId,
  enabled: boolean,
): Promise<void>;
```
```sql
-- seedTenantModules
INSERT INTO tenant_modules (tenant_id, module_id, enabled, enabled_at)
SELECT $1, unnest(ARRAY[
  'crm','customers','time_management','projects','tasks',
  'invoices','expenses','billing','calendar','marketing',
  'reports','kb','contractor_payouts','ai_assistant'
]), true, now()
ON CONFLICT DO NOTHING;
```
**Acceptance:**
- [ ] `seedTenantModules` produces 14 rows, none being `system`, all `enabled=true`.
- [ ] `getEnabledModuleIds` always contains `'system'` even though no DB row exists for it.
- [ ] `setModuleStates` with a multi-module disable writes all rows atomically (a forced mid-transaction error rolls back every change).
- [ ] Passing `system` to either setter throws before any DB write.

### Task 6: `GET /api/settings/modules`
**Blocks:** 9  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-api/src/routes/settings-modules.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount router)
**Steps:**
- [ ] Apply auth middleware then `requirePermission('settings:modules:read')`.
- [ ] Read `tid` from `c.get('session')`; load rows via `getTenantModules` and compute the enabled set (including `system`).
- [ ] For each toggleable module, enrich from the manifest: `displayName`, `alwaysOn`, `canDisable` (= not alwaysOn and enabled), `canEnable` (from `canEnable()`), `blockedBy` (= `missingHardDeps`), `dependencies` (manifest deps with current `enabled`), `dependedOnBy` (reverse manifest lookup with current `enabled`).
- [ ] Return modules in `MODULE_CARD_ORDER`. Exclude `system`.
**Schema / Interfaces:**
```ts
// Response 200
interface ModulesResponse {
  modules: Array<{
    id: ModuleId; displayName: string; enabled: boolean;
    enabledAt: string | null; disabledAt: string | null;
    alwaysOn: boolean; canDisable: boolean; canEnable: boolean;
    blockedBy: ModuleId[];
    dependencies: Array<{ moduleId: ModuleId; kind: DependencyKind; enabled: boolean }>;
    dependedOnBy: Array<{ moduleId: ModuleId; kind: DependencyKind; enabled: boolean }>;
  }>;
}
```
**Acceptance:**
- [ ] Returns 14 modules in card order; `system` absent.
- [ ] A request without `settings:modules:read` returns `403 { error: 'Forbidden' }`.
- [ ] `dependedOnBy` for `tasks` includes `time_management` (hard) and `projects` (soft).

### Task 7: `PATCH /api/settings/modules/:moduleId`
**Blocks:** 11  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-api/src/routes/settings-modules.ts`
**Steps:**
- [ ] Apply auth + `requirePermission('settings:modules:write')`. Validate `Origin` per upstream auth middleware (already enforced globally).
- [ ] If `:moduleId === 'system'` → `422 { error: 'module_always_on', module: 'system' }`.
- [ ] If row has `override_by_system_admin = true` → reject tenant toggle with `403 { error: 'forbidden' }` (system-admin-managed).
- [ ] On `enabled: true`: run `canEnable`; if `!allowed` → `400 { error: 'missing_hard_dependencies', missingDeps }`. Else enable just the target via `setModuleStates`.
- [ ] On `enabled: false`: compute `getCascadeDisables` + `getSoftImpacts`; disable target + cascade in one transaction via `setModuleStates`; return `updated` (target first, then cascade) and `softImpacts`.
- [ ] Bump the request-tenant module cache / signal app store refetch (response is source of truth).
**Schema / Interfaces:**
```ts
// Request body
{ enabled: boolean }
// Response 200
{ updated: ModuleId[]; softImpacts: Array<{ moduleId: ModuleId; impactDescription: string }> }
// 400 blocked enable
{ error: 'missing_hard_dependencies', missingDeps: ModuleId[] }
// 403 insufficient permission OR admin-overridden
{ error: 'forbidden' }
// 422 system module
{ error: 'module_always_on', module: 'system' }
```
**Acceptance:**
- [ ] Disabling `tasks` returns `updated: ['tasks','time_management','contractor_payouts']` and persists all three as `enabled=false` atomically.
- [ ] Enabling `billing` while `invoices` is disabled returns `400 missing_hard_dependencies` with `missingDeps:['invoices']` and writes nothing.
- [ ] `PATCH .../system` returns `422 module_always_on`.
- [ ] Disable sets `disabled_at` and `disabled_by` for every updated row.

### Task 8: `GET /api/settings/modules/:moduleId/impact` + admin override route
**Blocks:** 11  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-api/src/routes/settings-modules.ts` (impact endpoint)
- Create: `apps/zync-api/src/routes/admin-modules.ts` (admin override)
- Modify: `apps/zync-api/src/routes/index.ts` (mount admin router under admin app)
**Steps:**
- [ ] Impact: `requirePermission('settings:modules:read')`. Query `?action=disable|enable` (default `disable`). For `disable`: return `cascadeDisables`, `softImpacts`, `blockedBy: []`. For `enable`: return `blockedBy` (= `canEnable().missingHardDeps`), empty cascade/soft. No DB writes.
- [ ] Admin override: mount on admin router guarded by `requireAdminSession()` (`session.type === 'admin'`). Body `{ enabled: boolean, reason: string }`. Call `setModuleStateAdmin` (bypasses dependency checks, sets `override_by_system_admin=true`). Reject `system` with `422`.
- [ ] Write the override action to the audit log (call the audit-log writer if available in this wave; otherwise log via the structured logger with `action: 'module_override'`, `tenantId`, `moduleId`, `enabled`, `reason`, `adminId`).
**Schema / Interfaces:**
```ts
// GET impact 200
{
  action: 'disable' | 'enable';
  moduleId: ModuleId;
  cascadeDisables: ModuleId[];
  softImpacts: Array<{ moduleId: ModuleId; impactDescription: string }>;
  blockedBy: ModuleId[];
}
// PATCH /api/admin/tenants/:tenantId/modules/:moduleId
// body: { enabled: boolean, reason: string }
// 200: { updated: ModuleId[]; overrideApplied: true }
```
**Acceptance:**
- [ ] `GET .../tasks/impact?action=disable` returns `cascadeDisables: ['time_management','contractor_payouts']` and the soft impacts including `projects` and `ai_assistant`.
- [ ] Admin override sets `override_by_system_admin=true` and bypasses hard-dep checks (can enable `billing` even when `invoices` disabled).
- [ ] Admin route returns `403` for non-admin (`type !== 'admin'`) sessions.

### Task 9: Frontend module store
**Blocks:** 10, 11, 12  ·  **Blocked by:** 2, 6
**Files:**
- Create: `apps/zync-app/src/stores/module-store.ts`
- Modify: `apps/zync-app/src/app/boot.ts` (load modules on session boot)
**Steps:**
- [ ] Implement a Zustand store matching the spec interface; `isEnabled('system')` returns `true` without a lookup.
- [ ] `loadModules(tenantId)` fetches `GET /api/settings/modules` and populates `modules` keyed by `ModuleId`.
- [ ] `setEnabled(id, enabled)` mutates local state (used after a successful PATCH so nav/guards update immediately).
- [ ] Call `loadModules` once on app boot alongside the session fetch.
**Schema / Interfaces:**
```ts
interface ModuleStore {
  modules: Record<ModuleId, { enabled: boolean }>;
  isEnabled: (id: ModuleId) => boolean; // 'system' => true always
  setEnabled: (id: ModuleId, enabled: boolean) => void;
  loadModules: (tenantId: string) => Promise<void>;
}
```
**Acceptance:**
- [ ] `useModuleStore.getState().isEnabled('system')` is `true` before any load.
- [ ] After `loadModules`, disabled modules report `isEnabled === false`.

### Task 10: `ModuleGuard` + sidebar/cross-module gating
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/components/ModuleGuard.tsx`
- Modify: `apps/zync-app/src/components/Sidebar.tsx` (conditional nav items)
**Steps:**
- [ ] `<ModuleGuard moduleId>`: if `!isEnabled(moduleId)`, fire a toast "[Module Name] is not enabled for your workspace." and `<Navigate to="/" replace />`; else render children. Resolve display name from `MODULE_BY_ID`.
- [ ] Wrap every module route with `<ModuleGuard>`.
- [ ] Sidebar renders each nav item only when `isEnabled(moduleId)`; `system` has no nav item.
- [ ] Provide a reusable `useModuleEnabled(id)` selector for cross-module field hiding (e.g. hide the Customer field when `customers` disabled).
**Acceptance:**
- [ ] Navigating directly to a disabled module route redirects to `/` and shows the toast.
- [ ] Disabling a module hides its sidebar item without a page reload (store-driven).

### Task 11: `/settings/modules` page — cards, toggle, confirm modal
**Blocks:** 12  ·  **Blocked by:** 7, 8, 9
**Files:**
- Create: `apps/zync-app/src/routes/settings/modules/ModulesPage.tsx`
- Create: `apps/zync-app/src/routes/settings/modules/ModuleCard.tsx`
- Create: `apps/zync-app/src/routes/settings/modules/DisableConfirmModal.tsx`
- Modify: `apps/zync-app/src/routes/settings/index.tsx` (add Modules tab + route)
**Steps:**
- [ ] Route guarded by `settings:modules:read`; `MEMBER` and below → 403 → redirect to `/settings`. Toggling requires `settings:modules:write` (toggle disabled/hidden otherwise).
- [ ] Render cards in `MODULE_CARD_ORDER` from `GET /api/settings/modules`. Responsive grid: 3-col ≥1024px, 2-col 768–1023px, 1-col <768px (`grid-template-columns: repeat(3,1fr)` etc.). Card min-height 140px collapsed.
- [ ] Collapsed card: icon, display name, short description, green/grey toggle, blocked-state note "Requires: [Module Name]" (link scrolls to that card) when `!canEnable`, and a "Details" chevron expanding in-place.
- [ ] Expanded card: full description, Integration Status section (only Calendar/CRM/Invoices), "This module uses" (dependencies) and "Depended on by" lists with enabled dots and "Go to card" links; "No dependencies." when empty.
- [ ] On enable toggle: PATCH `{enabled:true}`; on `400 missing_hard_dependencies` show blocked badge (should not happen since UI pre-blocks). On success: toast "[X] enabled", `setEnabled` in store.
- [ ] On disable toggle: GET `/impact?action=disable` to populate `DisableConfirmModal` (Section 1 immediate hard disables, Section 2 soft degraded functionality — omitted if empty, Section 3 always-shown data notice). Confirm button label `[Disable]` or `[Disable N Modules]` when cascade count > 1. On confirm: PATCH `{enabled:false}`; toast "[X] disabled"; update store for every `updated` module.
**Acceptance:**
- [ ] Cards render in the exact spec order; blocked modules show greyed toggle + "Requires: …" link.
- [ ] Disabling `tasks` opens the modal listing Time Tracking + Contractor Payouts under Section 1 and the soft impacts under Section 2; confirm button reads "Disable 3 Modules".
- [ ] Section 2 is omitted when there are no soft impacts; Section 3 always renders.
- [ ] `MEMBER` visiting `/settings/modules` is redirected to `/settings`.

### Task 12: Onboarding "Choose your tools" + a11y / RTL / reduced-motion polish
**Blocks:** —  ·  **Blocked by:** 11
**Files:**
- Modify: `apps/zync-app/src/features/onboarding/` (modules step; legacy `routes/onboarding/ChooseToolsStep.tsx` removed)
- Modify: `apps/zync-app/src/routes/settings/modules/ModuleCard.tsx`
- Modify: `apps/zync-app/src/routes/settings/modules/DisableConfirmModal.tsx`
**Steps:**
- [ ] Onboarding step presents the 14 modules as checkboxes (checked = enabled). Unchecking calls the standard `PATCH /api/settings/modules/:id { enabled:false }` before the dashboard first loads — no special endpoint. `system` never shown.
- [ ] A11y: toggle is a real `role="switch"` with `aria-checked`, `aria-labelledby` (card title) and `aria-describedby` (description + blocked note); blocked toggles set `aria-disabled="true"`. Confirm modal uses `role="dialog"`, `aria-modal="true"`, focus-trap, labelled by its title, `Esc` cancels, returns focus to the originating toggle.
- [ ] i18n/RTL: all labels via i18n keys (`modules.<id>.*`); card grid and dependency lists are logical-property based (margin-inline, no left/right) so Hebrew RTL mirrors correctly; "Go to card" links use logical directional icons.
- [ ] Reduced-motion: card expand/collapse and toggle transitions respect `prefers-reduced-motion: reduce` (no animation, instant state change).
**Acceptance:**
- [ ] Keyboard-only user can toggle a module and operate the confirm modal (focus trapped, Esc cancels, focus restored).
- [ ] In Hebrew/RTL the grid and dependency lists mirror with no hardcoded left/right offsets.
- [ ] With `prefers-reduced-motion: reduce`, expand/collapse is instant.
- [ ] Unchecking a module in onboarding disables it via the standard PATCH before the dashboard loads.

### Task 13: `requireModuleEnabled` API middleware (foundation export for all module routes)
**Blocks:** —  ·  **Blocked by:** 2, 5
**Files:**
- Create: `apps/zync-api/src/middleware/require-module-enabled.ts`
- Modify: `apps/zync-api/src/middleware/index.ts` (export)
**Steps:**
- [ ] Implement a Hono middleware factory `requireModuleEnabled(moduleId: ModuleId)` that every downstream module's routes wrap themselves in (this spec is "Referenced by: all module specs"). It is the single source of the API-side `403 { error: "module_disabled" }` shape mandated by the spec's Data Behavior table and After-Disable §5.
- [ ] Read `tid` from `c.get('session')`; resolve the tenant's enabled set via `getEnabledModuleIds(db, tid)`. Cache the enabled set per-request (set on `c` the first time) so multiple guarded routes in one request do not re-query.
- [ ] If `moduleId === 'system'` → always allowed (never queried). If the module is enabled → `next()`. If disabled → `403 { error: 'module_disabled', module: moduleId }`.
- [ ] Run this middleware *after* the auth/permission middleware (it needs a populated session) and before the route handler.
- [ ] Export from `apps/zync-api` middleware barrel (and re-export the `ModuleId` type from `@zync/modules`) so all downstream module plans consume one canonical guard and one canonical error shape.
**Schema / Interfaces:**
```ts
import type { ModuleId } from '@zync/modules';
import type { MiddlewareHandler } from 'hono';

/** Wraps a module's routes; returns 403 { error:'module_disabled', module } when the tenant has it disabled. */
export function requireModuleEnabled(moduleId: ModuleId): MiddlewareHandler;
// 403 body: { error: 'module_disabled', module: ModuleId }
```
**Acceptance:**
- [ ] A request to a route guarded by `requireModuleEnabled('tasks')` when `tasks` is disabled for the tenant returns `403 { error: 'module_disabled', module: 'tasks' }`.
- [ ] When the module is enabled, the middleware calls `next()` and the handler runs.
- [ ] `requireModuleEnabled('system')` always passes without a DB query.
- [ ] Two guarded routes in a single request perform at most one `getEnabledModuleIds` query (per-request cache).
