# Knowledge Base Settings — Implementation Plan

**Spec:** docs/specs/2026-06-01-settings-kb.md  ·  **Slug:** settings-kb  ·  **Wave:** 14
**Depends on:** foundation-auth-rbac, kb-article-editor, kb-module, kb-versioning

## Goal
Add a `/settings/kb` workspace-configuration page plus the supporting API and a publication review workflow on top of the existing Knowledge Base module. Delivers: (1) tenant-level KB settings (require-review gate, versioning toggle, default space), (2) full space management (create, rename, reorder, delete-if-empty), and (3) a `PENDING_REVIEW` article lifecycle state with an OWNER/ADMIN review queue at `/kb/review`. This closes the gaps left by `kb-module` (spaces + `status DRAFT|PUBLISHED`) and `kb-article-editor` (editing) which never defined workspace settings or an approval gate.

## Architecture
Consumes upstream tables `kb_spaces` and `kb_articles` (from `kb-module`) and `kb_article_versions` (from `kb-versioning`); reuses the auth/RBAC layer (`authMiddleware`, `requirePermission`, `tenantQuery`, `buildPaginated`) from `foundation-auth-rbac`.

The spec's `ALTER TABLE tenant_settings ...` is honored directly: `tenant_settings` is the canonical per-tenant typed module-config table (base owned by `foundation-auth-rbac`). The three KB settings columns are added to it via `ALTER ... ADD COLUMN IF NOT EXISTS`. `kb_default_space_id` is a real UUID→UUID FK with `ON DELETE SET NULL` — exactly why KB config is a typed column on `tenant_settings` rather than a key in `tenants.settings` JSONB (which could not enforce the FK). (`ai_tenant_settings`, owned by `system-ai`, is a separate AI-specific table.)

`kb_spaces` gains a `position` column (spec Schema Delta); `description` already exists on the `kb_spaces` base table (owned by `kb-module`) and is consumed, not re-added. The `kb_articles.status` column (`TEXT DEFAULT 'DRAFT'` from `kb-module`, no CHECK constraint upstream) is extended at the application layer to also accept `'PENDING_REVIEW'`; no DDL migration is required for that value, but new article-mutation paths must validate the three-value set.

Data flow: React settings page → Hono API routes (`/api/settings/kb`, `/api/kb/spaces*`, `/api/kb/review`, `/api/kb/articles/:id/approve|reject`) → Drizzle repository functions scoped by `tenantQuery` → Neon Postgres via Hyperdrive. Approve/reject transitions write the new `kb_articles.status` and (when versioning enabled) create a `kb_article_versions` snapshot via the existing versioning path.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers), Drizzle ORM, Zod validation (`require-zod-validation-in-routes`), `@zync/auth` middleware.
- **DB package:** `@zync/db` — Drizzle schema + repository functions; Neon Postgres via Hyperdrive binding.
- **App UI:** `apps/zync-app` (Vite + React), `@zync/ui` primitives (`Card`, `Switch`, `Radio`, `Select`, `Button`, `Dialog`, `DataTable`, `Toast`, `EmptyState`), `@zync/types`.
- **Bindings:** Hyperdrive (Postgres). No new KV/R2/Queue bindings.
- **i18n/RTL:** all labels via `@zync/i18n` translation keys; layout direction via `useDirection`; drag handles and action buttons mirror under RTL.
- **A11y:** radio groups use `role="radiogroup"`; reorder exposes keyboard move (ArrowUp/ArrowDown) alongside pointer drag; respects `prefers-reduced-motion` for drag animation.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Schema | 1 | `packages/db/src/schema/kb.ts`, `packages/db/migrations/*` | No (foundation for all) |
| B — Repos | 2, 3, 4 | `packages/db/src/repos/kb-settings.ts`, `kb-spaces.ts`, `kb-review.ts` | Yes (independent files) |
| C — API | 5, 6, 7 | `apps/zync-api/src/routes/settings-kb.ts`, `kb-spaces.ts`, `kb-review.ts` | Yes after B |
| D — UI | 8, 9, 10 | `apps/zync-app/src/pages/settings/kb/*`, `apps/zync-app/src/pages/kb/review/*` | Yes after C |
| E — Integration | 11 | editor/publish call-sites, nav registration | No (cross-cutting wiring) |

## Tasks

### Task 1: Schema delta — KB settings table, space columns, status value
**Blocks:** 2, 3, 4  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/kb.ts`
- Create: `packages/db/migrations/<timestamp>_settings_kb.sql`
**Steps:**
- [ ] Add `description` and `position` columns to the existing `kb_spaces` Drizzle table definition.
- [ ] Add the three KB settings columns to `tenant_settings` (base table owned by `foundation-auth-rbac`) via `ALTER ... ADD COLUMN IF NOT EXISTS`, with the spec's exact defaults and the `kb_default_space_id` FK semantics. Do NOT create a separate settings table.
- [ ] Add a Drizzle `text` enum-style helper / constant `KB_ARTICLE_STATUSES = ['DRAFT','PENDING_REVIEW','PUBLISHED']` and export it for app-layer validation (no DB CHECK is added, matching the upstream column which has none).
- [ ] Write the forward migration SQL using `ADD COLUMN IF NOT EXISTS` for idempotency.
- [ ] Add an index on `kb_articles(tenant_id, status)` to make the review-queue query (`status = 'PENDING_REVIEW'`) efficient.
**Schema / Interfaces:**
```sql
-- Spec Schema Delta: extend kb_spaces (kb-module owns the base table).
-- description is owned by kb-module (in its kb_spaces CREATE) — NOT re-added here.
-- This module adds only the space-ordering column:
ALTER TABLE kb_spaces
  ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0;

-- Tenant-level KB settings live on the shared tenant_settings table (base owned by
-- foundation-auth-rbac). kb_default_space_id is a real UUID FK with ON DELETE SET NULL
-- (a typed column on tenant_settings — the reason KB config is not in tenants.settings JSONB).
ALTER TABLE tenant_settings
  ADD COLUMN IF NOT EXISTS kb_require_review     BOOLEAN NOT NULL DEFAULT false,
  ADD COLUMN IF NOT EXISTS kb_versioning_enabled BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN IF NOT EXISTS kb_default_space_id   UUID REFERENCES kb_spaces(id) ON DELETE SET NULL;

-- Review-queue query support
CREATE INDEX IF NOT EXISTS idx_kb_articles_tenant_status
  ON kb_articles(tenant_id, status);

-- kb_articles.status (TEXT DEFAULT 'DRAFT', no CHECK upstream) now also
-- accepts 'PENDING_REVIEW'. Validated in application logic, not DDL.
-- Allowed set: 'DRAFT' | 'PENDING_REVIEW' | 'PUBLISHED'
```
```typescript
export const KB_ARTICLE_STATUSES = ['DRAFT', 'PENDING_REVIEW', 'PUBLISHED'] as const;
export type KbArticleStatus = (typeof KB_ARTICLE_STATUSES)[number];

// Projection over the KB columns of the shared tenant_settings row.
export interface TenantKbSettings {
  tenant_id: string;
  kb_require_review: boolean;
  kb_versioning_enabled: boolean;
  kb_default_space_id: string | null;
}
```
**Acceptance:**
- [ ] Migration applies cleanly on a fresh Neon branch and is idempotent on re-run.
- [ ] `tenant_settings.kb_default_space_id` set to NULL automatically when its referenced space is deleted.
- [ ] `kb_spaces.position` defaults to 0 (`description` is owned by kb-module).

### Task 2: KB settings repository
**Blocks:** 5  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/repos/kb-settings.ts`
**Steps:**
- [ ] Implement `getKbSettings(db, tenantId)` — selects the KB columns from the tenant's `tenant_settings` row; if none exists, return canonical defaults (`kb_require_review=false`, `kb_versioning_enabled=true`, `kb_default_space_id=null`) without inserting.
- [ ] Implement `upsertKbSettings(db, tenantId, patch)` — `INSERT INTO tenant_settings (tenant_id, ...) ON CONFLICT (tenant_id) DO UPDATE` applying only the provided KB fields and bumping `updated_at`.
- [ ] Validate `kb_default_space_id` (when provided non-null) belongs to the same tenant before write; throw a typed error otherwise.
- [ ] Export both functions from the package index.
**Schema / Interfaces:**
```typescript
export function getKbSettings(db: Db, tenantId: string): Promise<TenantKbSettings>;
export function upsertKbSettings(
  db: Db,
  tenantId: string,
  patch: Partial<Pick<TenantKbSettings,
    'kb_require_review' | 'kb_versioning_enabled' | 'kb_default_space_id'>>,
): Promise<TenantKbSettings>;
```
**Acceptance:**
- [ ] First read for a tenant with no row returns defaults and does not create a row.
- [ ] `upsertKbSettings` round-trips each field; partial patch leaves untouched fields unchanged.
- [ ] Setting `kb_default_space_id` to a space owned by another tenant is rejected.

### Task 3: KB spaces management repository
**Blocks:** 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/repos/kb-spaces.ts`
**Steps:**
- [ ] `listSpacesWithCounts(db, tenantId)` — returns spaces ordered by `position` then `name`, each with `article_count` (LEFT JOIN + COUNT on `kb_articles`) and `customer_id`.
- [ ] `createSpace(db, tenantId, name, createdBy)` — inserts with `position = COALESCE(MAX(position),-1)+1` for the tenant; generates a unique `slug` from `name` (slugify + de-dup against `UNIQUE(tenant_id, slug)`), `type='internal'`.
- [ ] `renameSpace(db, tenantId, spaceId, name)` — updates `name` and regenerates/updates `slug`; preserves uniqueness.
- [ ] `reorderSpace(db, tenantId, spaceId, position)` — updates the target `position`; renormalizes sibling positions to a contiguous 0..n sequence in a single transaction.
- [ ] `deleteSpaceIfEmpty(db, tenantId, spaceId)` — returns `{ deleted: false, articleCount }` if `kb_articles` exist in the space; otherwise deletes and returns `{ deleted: true }`. Refuse deletion when the space has a non-null `customer_id` (client-vault) regardless of count.
- [ ] All queries scoped via `tenantQuery`.
**Schema / Interfaces:**
```typescript
export interface KbSpaceListItem {
  id: string;
  name: string;
  slug: string;
  position: number;
  customer_id: string | null;
  article_count: number;
}
export function listSpacesWithCounts(db: Db, tenantId: string): Promise<KbSpaceListItem[]>;
export function createSpace(db: Db, tenantId: string, name: string, createdBy: string): Promise<KbSpaceListItem>;
export function renameSpace(db: Db, tenantId: string, spaceId: string, name: string): Promise<KbSpaceListItem>;
export function reorderSpace(db: Db, tenantId: string, spaceId: string, position: number): Promise<KbSpaceListItem[]>;
export function deleteSpaceIfEmpty(
  db: Db, tenantId: string, spaceId: string,
): Promise<{ deleted: boolean; articleCount?: number }>;
```
**Acceptance:**
- [ ] New space receives `position = max+1`; first space gets `position = 0`.
- [ ] Deleting a space that has articles returns `deleted:false` with the article count and removes nothing.
- [ ] Deleting a client-vault space (`customer_id != null`) is refused.
- [ ] Reorder renormalizes positions to 0..n with no gaps or duplicates.

### Task 4: KB review-queue repository
**Blocks:** 7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/repos/kb-review.ts`
**Steps:**
- [ ] `listPendingReview(db, tenantId)` — selects `kb_articles` where `status='PENDING_REVIEW'`, joined to `kb_spaces` (space name) and `users` (created_by name), ordered by `updated_at` ASC (oldest first). Use the `idx_kb_articles_tenant_status` index.
- [ ] `submitForReview(db, tenantId, articleId, userId)` — transitions an article `DRAFT → PENDING_REVIEW`; rejects if current status is not `DRAFT`.
- [ ] `approveArticle(db, tenantId, articleId, userId)` — transitions `PENDING_REVIEW → PUBLISHED`, sets `published_at = now()`, `updated_by`, `updated_at`. If the tenant's `kb_versioning_enabled` is true, create a version snapshot first (call the `kb-versioning` snapshot path). Wrap in a transaction; emit audit entry `kb_article.approved`.
- [ ] `rejectArticle(db, tenantId, articleId, userId, feedback?)` — transitions `PENDING_REVIEW → DRAFT`, records optional `feedback` in the audit/communication trail; emits audit entry `kb_article.rejected` with `{ feedback }`.
- [ ] Each transition validates the source status and is a no-op error (typed) if the article is not in the expected state.
**Schema / Interfaces:**
```typescript
export interface KbReviewItem {
  id: string;
  title: string;
  space_id: string;
  space_name: string;
  submitted_at: string;        // kb_articles.updated_at at PENDING_REVIEW transition
  created_by_name: string;
}
export function listPendingReview(db: Db, tenantId: string): Promise<KbReviewItem[]>;
export function submitForReview(db: Db, tenantId: string, articleId: string, userId: string): Promise<void>;
export function approveArticle(db: Db, tenantId: string, articleId: string, userId: string): Promise<void>;
export function rejectArticle(
  db: Db, tenantId: string, articleId: string, userId: string, feedback?: string,
): Promise<void>;
```
**Acceptance:**
- [ ] `approveArticle` on a `PENDING_REVIEW` article yields `status='PUBLISHED'` with `published_at` set; on a non-pending article it errors without mutation.
- [ ] When `kb_versioning_enabled=true`, approval creates exactly one new `kb_article_versions` snapshot.
- [ ] `rejectArticle` returns the article to `DRAFT` and persists feedback in the audit trail.
- [ ] Queue list excludes non-pending articles and is tenant-scoped.

### Task 5: Settings KB API routes
**Blocks:** 8  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/routes/settings-kb.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] `GET /api/settings/kb` — `authMiddleware` + `requirePermission('users:manage')`; returns `{ kb_require_review, kb_versioning_enabled, kb_default_space_id }` via `getKbSettings`.
- [ ] `PATCH /api/settings/kb` — same guard; Zod body `{ kb_require_review?, kb_versioning_enabled?, kb_default_space_id? }`; calls `upsertKbSettings`; returns the updated settings.
- [ ] Map the repo's same-tenant `kb_default_space_id` validation failure to HTTP 422.
- [ ] Register the router in the API app entry.
**Schema / Interfaces:**
```typescript
// Zod
const patchKbSettingsSchema = z.object({
  kb_require_review: z.boolean().optional(),
  kb_versioning_enabled: z.boolean().optional(),
  kb_default_space_id: z.string().uuid().nullable().optional(),
});
// Routes
// GET   /api/settings/kb   -> { kb_require_review, kb_versioning_enabled, kb_default_space_id }
// PATCH /api/settings/kb   -> updated settings object
```
**Acceptance:**
- [ ] Non-`users:manage` callers receive 403 on both routes.
- [ ] PATCH with an invalid (non-uuid) `kb_default_space_id` returns 400; cross-tenant space returns 422.
- [ ] GET on a tenant with no settings row returns the defaults.

### Task 6: KB spaces API routes
**Blocks:** 9  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/routes/kb-spaces.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] `GET /api/kb/spaces` — `authMiddleware` (authenticated, any role); returns `[{ id, name, slug, position, customer_id, article_count }]` from `listSpacesWithCounts`.
- [ ] `POST /api/kb/spaces` — `requirePermission('users:manage')`; Zod `{ name: string (1..120) }`; calls `createSpace` with the session user id; returns the created space.
- [ ] `PATCH /api/kb/spaces/:id` — `requirePermission('users:manage')`; Zod `{ name?: string, position?: number }`; if `name` present call `renameSpace`, if `position` present call `reorderSpace`; return updated space (or full reordered list when reordered).
- [ ] `DELETE /api/kb/spaces/:id` — `requirePermission('users:manage')`; calls `deleteSpaceIfEmpty`; on `{deleted:false}` return HTTP 409 with `{ articleCount, message: "Move or delete {N} articles before removing this space." }`; on vault refusal return 409 with a vault-specific message.
- [ ] Register the router.
**Schema / Interfaces:**
```typescript
const createSpaceSchema = z.object({ name: z.string().min(1).max(120) });
const patchSpaceSchema = z.object({
  name: z.string().min(1).max(120).optional(),
  position: z.number().int().min(0).optional(),
});
// GET    /api/kb/spaces       -> KbSpaceListItem[]
// POST   /api/kb/spaces       -> KbSpaceListItem
// PATCH  /api/kb/spaces/:id   -> KbSpaceListItem | KbSpaceListItem[]
// DELETE /api/kb/spaces/:id   -> 204 | 409 { articleCount, message }
```
**Acceptance:**
- [ ] `GET` is reachable by any authenticated tenant member; write routes require `users:manage`.
- [ ] DELETE on a non-empty space returns 409 with the exact spec message and the article count.
- [ ] DELETE on a vault space returns 409 and never deletes.
- [ ] PATCH reorder returns the renormalized ordered list.

### Task 7: KB review API routes
**Blocks:** 10  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-api/src/routes/kb-review.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] `GET /api/kb/review` — `requirePermission('users:manage')`; returns `[{ id, title, space_id, space_name, submitted_at, created_by_name }]` from `listPendingReview`.
- [ ] `POST /api/kb/articles/:id/approve` — `requirePermission('users:manage')`; calls `approveArticle`; returns the updated article status.
- [ ] `POST /api/kb/articles/:id/reject` — `requirePermission('users:manage')`; Zod `{ feedback?: string (<=2000) }`; calls `rejectArticle`; returns updated status.
- [ ] Add `POST /api/kb/articles/:id/submit-review` — `requirePermission('kb:publish')` OR any `kb:write` author (per spec table: MEMBER with/without `kb:publish` may submit); calls `submitForReview`. Guard: only allowed when the tenant's `kb_require_review=true`.
- [ ] Map invalid source-status transitions to HTTP 409.
- [ ] Register the router.
**Schema / Interfaces:**
```typescript
const rejectSchema = z.object({ feedback: z.string().max(2000).optional() });
// GET  /api/kb/review                      -> KbReviewItem[]
// POST /api/kb/articles/:id/submit-review  -> { status: 'PENDING_REVIEW' }
// POST /api/kb/articles/:id/approve        -> { status: 'PUBLISHED' }
// POST /api/kb/articles/:id/reject         -> { status: 'DRAFT' }
```
**Acceptance:**
- [ ] Review-queue routes return 403 for non-`users:manage` callers.
- [ ] Approve/reject on an article not in `PENDING_REVIEW` returns 409.
- [ ] `submit-review` is rejected (409) when `kb_require_review=false`.
- [ ] OWNER/ADMIN can directly publish via the existing editor publish route without submit-review (bypass preserved).

### Task 8: Settings page — Publication Workflow & Default Space sections
**Blocks:** 11  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/pages/settings/kb/SettingsKbPage.tsx`
- Create: `apps/zync-app/src/pages/settings/kb/useKbSettings.ts`
- Create: `apps/zync-app/src/pages/settings/kb/index.ts`
**Steps:**
- [ ] Build `useKbSettings` react-query hook: `GET /api/settings/kb` query + `PATCH` mutation with optimistic update and `toast` on success/error.
- [ ] Render the **Publication Workflow** `Card`: a `role="radiogroup"` with two `Radio` options — "Direct publish" vs "Require review (ADMIN/OWNER must approve)" bound to `kb_require_review`; and a versioning `role="radiogroup"` / `Switch` bound to `kb_versioning_enabled` ("On (recommended)" / "Off").
- [ ] Render the **Default Space** `Card`: a `Select` of spaces (from `GET /api/kb/spaces`) bound to `kb_default_space_id`, with a "— None —" option mapping to null.
- [ ] Wire a single **[Save changes]** `Button` that PATCHes the changed fields; disable while pending; show validation/toast feedback.
- [ ] Gate the page behind `users:manage`; render an `EmptyState`/`ErrorPage` (403) for unauthorized users.
- [ ] All copy via i18n keys; ensure RTL mirroring via `useDirection`.
**Acceptance:**
- [ ] Toggling require-review and saving persists and re-reads correctly.
- [ ] Default-space select lists all spaces and supports clearing to null.
- [ ] Radio groups are keyboard-navigable and announce state to screen readers.

### Task 9: Settings page — Spaces management section
**Blocks:** 11  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/pages/settings/kb/SpacesManager.tsx`
- Create: `apps/zync-app/src/pages/settings/kb/useKbSpaces.ts`
**Steps:**
- [ ] `useKbSpaces` hook: list query + create/rename/reorder/delete mutations against `/api/kb/spaces`.
- [ ] Render the **Spaces** `Card`: an ordered, drag-reorderable list (drag handle `≡`) of spaces with `[Rename]` and `[×]` actions per row; show a "portal" badge for rows with `customer_id` and disable their delete action.
- [ ] Drag reorder → `PATCH /api/kb/spaces/:id { position }`; provide keyboard reorder (ArrowUp/ArrowDown on a focused handle) and honor `prefers-reduced-motion` (disable drag animation).
- [ ] `[+ Add space]` → inline input / `Dialog` → `POST /api/kb/spaces { name }`.
- [ ] `[Rename]` → inline editable field / `Dialog` → `PATCH { name }`.
- [ ] `[×]` delete → if API returns 409 with `articleCount`, surface the message "Move or delete {N} articles before removing this space." via `toast`/inline error and keep the row.
- [ ] Vault rows: delete disabled with tooltip directing the user to the customer detail page.
**Acceptance:**
- [ ] Reorder persists and survives reload; keyboard reorder works.
- [ ] Deleting a non-empty space shows the blocking message and does not remove the row.
- [ ] Vault (portal-badge) spaces cannot be deleted from this page.

### Task 10: KB Review Queue page (`/kb/review`)
**Blocks:** 11  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/pages/kb/review/KbReviewPage.tsx`
- Create: `apps/zync-app/src/pages/kb/review/useKbReview.ts`
**Steps:**
- [ ] `useKbReview` hook: `GET /api/kb/review` list query + approve/reject mutations.
- [ ] Render a `DataTable` with columns Article, Space, Submitted (relative time), By; header shows pending count "(N pending)".
- [ ] Per-row actions: `[Preview]` (navigates to the article read view), `[Approve]` (`POST .../approve`), `[Reject]` (opens a `Dialog` with optional feedback `Textarea` → `POST .../reject`).
- [ ] On approve/reject success: optimistic row removal + `toast`; refetch count.
- [ ] Render an `EmptyState` ("No articles pending review") when the queue is empty.
- [ ] Gate the route behind `users:manage`; non-authorized users get a 403 page.
**Acceptance:**
- [ ] Page lists only `PENDING_REVIEW` articles for the tenant, oldest first.
- [ ] Approve removes the row and the article becomes `PUBLISHED`.
- [ ] Reject with feedback returns the article to `DRAFT` and removes it from the queue.
- [ ] Empty queue shows the empty state; visible to OWNER/ADMIN only.

### Task 11: Integration — editor publish gating, submit-for-review, navigation
**Blocks:** —  ·  **Blocked by:** 8, 9, 10
**Files:**
- Modify: `apps/zync-app/src/pages/kb/editor/*` (publish controls from `kb-article-editor`)
- Modify: `apps/zync-app/src/app/routes.tsx` (or equivalent route registry)
- Modify: `apps/zync-app/src/app/settings-nav.ts` (settings navigation registry)
**Steps:**
- [ ] In the article editor, fetch KB settings; when `kb_require_review=true` and the current user is a MEMBER (lacks OWNER/ADMIN), replace the **Publish** control with **Submit for review** wired to `POST /api/kb/articles/:id/submit-review`; show a "Pending review" badge when `status='PENDING_REVIEW'`.
- [ ] When `kb_require_review=true` and the user is OWNER/ADMIN, keep direct **Publish** (bypass) available per the spec role matrix.
- [ ] When `kb_require_review=false`, retain the existing `DRAFT → PUBLISHED` direct-publish flow unchanged.
- [ ] Honor `kb_versioning_enabled` in the editor: hide/disable the [Save & version] affordance (or skip snapshot creation) when versioning is off; new-article creation uses `kb_default_space_id` as the preselected space when set.
- [ ] Register `/settings/kb` (gated `users:manage`) and `/kb/review` (gated `users:manage`) routes and add a "Knowledge Base" entry to the settings navigation.
**Acceptance:**
- [ ] MEMBER author with review enabled sees "Submit for review", not "Publish".
- [ ] OWNER/ADMIN retains direct publish with review enabled.
- [ ] With review disabled, publish behaves exactly as before.
- [ ] New articles default to the configured default space when one is set.
- [ ] `/settings/kb` and `/kb/review` are reachable from navigation and enforce `users:manage`.
